diff --git a/build.gradle b/build.gradle index f1cca38b..c2ccb1b2 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,12 @@ dependencies { implementation 'org.apache.tika:tika-parsers-standard-package:2.9.2' implementation 'org.json:json:20250107' + // Google Calendar API + implementation 'com.google.api-client:google-api-client:2.7.2' + implementation 'com.google.oauth-client:google-oauth-client-jetty:1.36.0' + implementation 'com.google.apis:google-api-services-calendar:v3-rev20250404-2.0.0' + implementation 'com.google.auth:google-auth-library-oauth2-http:1.30.1' + //QueryDsl implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' diff --git a/src/main/java/com/bmilab/backend/domain/leave/dto/response/LeaveDetail.java b/src/main/java/com/bmilab/backend/domain/leave/dto/response/LeaveDetail.java index 94892cc8..f6de46eb 100644 --- a/src/main/java/com/bmilab/backend/domain/leave/dto/response/LeaveDetail.java +++ b/src/main/java/com/bmilab/backend/domain/leave/dto/response/LeaveDetail.java @@ -6,8 +6,11 @@ import com.bmilab.backend.domain.user.dto.response.UserSummary; import io.swagger.v3.oas.annotations.media.Schema; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import lombok.Builder; @Builder @@ -43,9 +46,13 @@ public record LeaveDetail( LocalDateTime processedAt, @Schema(description = "신청 일시", example = "2025-04-23T15:30:00") - LocalDateTime applicatedAt + LocalDateTime applicatedAt, + + @Schema(description = "Google 캘린더 추가 링크") + String googleCalendarLink ) { public static LeaveDetail from(Leave leave) { + String eventTitle = leave.getUser().getName() + " " + leave.getType().getDescription(); return LeaveDetail .builder() .leaveId(leave.getId()) @@ -59,6 +66,20 @@ public static LeaveDetail from(Leave leave) { .processor(leave.getProcessor() == null ? null : UserSummary.from(leave.getProcessor())) .processedAt(leave.getProcessedAt()) .applicatedAt(leave.getApplicatedAt()) + .googleCalendarLink(buildGoogleCalendarLink(eventTitle, leave.getStartDate(), leave.getEndDate())) .build(); } + + private static String buildGoogleCalendarLink(String title, LocalDate startDate, LocalDate endDate) { + DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyyMMdd"); + String encodedTitle = URLEncoder.encode(title, StandardCharsets.UTF_8); + String start = startDate.format(df); + // AIDEV-NOTE: 종일 이벤트 — endDate는 exclusive이므로 +1일 + LocalDate effectiveEnd = (endDate != null ? endDate : startDate).plusDays(1); + String end = effectiveEnd.format(df); + + return "https://calendar.google.com/calendar/render?action=TEMPLATE" + + "&text=" + encodedTitle + + "&dates=" + start + "/" + end; + } } 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 0a6aab94..63e8005a 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 @@ -78,6 +78,9 @@ public class Leave extends BaseTimeEntity { @Column(name = "applicated_at", nullable = false) private LocalDateTime applicatedAt; + @Column(name = "google_event_id") + private String googleEventId; + public void approve(User processor, LocalDateTime now) { status = LeaveStatus.APPROVED; this.processor = processor; @@ -107,6 +110,10 @@ public boolean isAnnualLeave() { return type == LeaveType.ANNUAL; } + public void updateGoogleEventId(String googleEventId) { + this.googleEventId = googleEventId; + } + public void update(LocalDate startDate, LocalDate endDate, LeaveType type, Double leaveCount, String reason) { this.startDate = startDate; this.endDate = endDate; 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 00ef9134..949514a0 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 @@ -13,10 +13,10 @@ import com.bmilab.backend.domain.leave.repository.LeaveRepository; import com.bmilab.backend.domain.leave.repository.UserLeaveRepository; import com.bmilab.backend.domain.user.entity.User; -import com.bmilab.backend.domain.user.exception.UserErrorCode; -import com.bmilab.backend.domain.user.repository.UserRepository; import com.bmilab.backend.domain.user.service.UserService; +import com.bmilab.backend.global.config.GoogleCalendarConfig; import com.bmilab.backend.global.exception.ApiException; +import com.bmilab.backend.global.external.calendar.GoogleCalendarService; import java.time.LocalDate; import java.time.LocalDateTime; @@ -25,7 +25,6 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -37,6 +36,8 @@ public class LeaveService { private final LeaveRepository leaveRepository; private final UserLeaveRepository userLeaveRepository; private final UserService userService; + private final GoogleCalendarService googleCalendarService; + private final GoogleCalendarConfig googleCalendarConfig; public LeaveFindAllResponse getLeaves(LocalDate startDate, LocalDate endDate) { List leaves = @@ -124,6 +125,17 @@ public void approveLeave(Long processorId, long leaveId) { userLeave.useLeave(leave.getLeaveCount(), leave.isAnnualLeave()); leave.approve(processor, LocalDateTime.now()); + + if (googleCalendarService.isEnabled()) { + String eventTitle = buildLeaveEventTitle(user.getName(), leave.getType()); + String eventId = googleCalendarService.createEvent( + googleCalendarConfig.getLeaveCalendarId(), + eventTitle, + leave.getStartDate(), + leave.getEndDate() + ); + leave.updateGoogleEventId(eventId); + } } @Transactional @@ -181,6 +193,27 @@ public void updateLeaveByAdmin(long leaveId, AdminUpdateLeaveRequest request) { // 휴가 정보 업데이트 leave.update(startDate, request.endDate(), newType, newLeaveCount, request.reason()); + + if (googleCalendarService.isEnabled()) { + String eventTitle = buildLeaveEventTitle(user.getName(), newType); + if (leave.getGoogleEventId() != null) { + googleCalendarService.updateEvent( + googleCalendarConfig.getLeaveCalendarId(), + leave.getGoogleEventId(), + eventTitle, + startDate, + request.endDate() + ); + } else { + String eventId = googleCalendarService.createEvent( + googleCalendarConfig.getLeaveCalendarId(), + eventTitle, + startDate, + request.endDate() + ); + leave.updateGoogleEventId(eventId); + } + } } @Transactional @@ -212,13 +245,20 @@ 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()); + + if (googleCalendarService.isEnabled() && leave.getGoogleEventId() != null) { + googleCalendarService.deleteEvent( + googleCalendarConfig.getLeaveCalendarId(), + leave.getGoogleEventId() + ); + } } leaveRepository.delete(leave); @@ -235,4 +275,8 @@ private void validateNoDuplicateLeave(Long userId, LocalDate startDate, LocalDat throw new ApiException(LeaveErrorCode.LEAVE_DUPLICATE); } } + + private String buildLeaveEventTitle(String userName, LeaveType type) { + return userName + " " + type.getDescription(); + } } diff --git a/src/main/java/com/bmilab/backend/domain/research/award/controller/AwardController.java b/src/main/java/com/bmilab/backend/domain/research/award/controller/AwardController.java index e3837b75..6aaf74a9 100644 --- a/src/main/java/com/bmilab/backend/domain/research/award/controller/AwardController.java +++ b/src/main/java/com/bmilab/backend/domain/research/award/controller/AwardController.java @@ -30,7 +30,7 @@ public ResponseEntity createAward( @AuthenticationPrincipal UserAuthInfo userAuthInfo, @RequestBody @Valid CreateAwardRequest request ) { - AwardResponse response = awardService.createAward(request); + AwardResponse response = awardService.createAward(userAuthInfo.getUserId(), request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -40,7 +40,8 @@ public ResponseEntity updateAward( @PathVariable Long awardId, @RequestBody @Valid UpdateAwardRequest request ) { - AwardResponse response = awardService.updateAward(awardId, request); + boolean isAdmin = userAuthInfo.getUser().getRole() == Role.ADMIN; + AwardResponse response = awardService.updateAward(userAuthInfo.getUserId(), isAdmin, awardId, request); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/bmilab/backend/domain/research/award/entity/Award.java b/src/main/java/com/bmilab/backend/domain/research/award/entity/Award.java index 1f5853c5..5deb9869 100644 --- a/src/main/java/com/bmilab/backend/domain/research/award/entity/Award.java +++ b/src/main/java/com/bmilab/backend/domain/research/award/entity/Award.java @@ -2,6 +2,7 @@ import com.bmilab.backend.domain.project.entity.Project; import com.bmilab.backend.domain.task.entity.Task; +import com.bmilab.backend.domain.user.entity.User; import com.bmilab.backend.global.entity.BaseTimeEntity; import lombok.AccessLevel; import lombok.Builder; @@ -47,8 +48,12 @@ public class Award extends BaseTimeEntity { @JoinColumn(name = "task_id") private Task task; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + private User createdBy; + @Builder - public Award(String recipients, LocalDate awardDate, String hostInstitution, String competitionName, String awardName, String presentationTitle, Project project, Task task) { + public Award(String recipients, LocalDate awardDate, String hostInstitution, String competitionName, String awardName, String presentationTitle, Project project, Task task, User createdBy) { this.recipients = recipients; this.awardDate = awardDate; this.hostInstitution = hostInstitution; @@ -57,6 +62,7 @@ public Award(String recipients, LocalDate awardDate, String hostInstitution, Str this.presentationTitle = presentationTitle; this.project = project; this.task = task; + this.createdBy = createdBy; } public void update(String recipients, LocalDate awardDate, String hostInstitution, String competitionName, String awardName, String presentationTitle, Project project, Task task) { diff --git a/src/main/java/com/bmilab/backend/domain/research/award/service/AwardService.java b/src/main/java/com/bmilab/backend/domain/research/award/service/AwardService.java index 457d82bf..fee9b6d9 100644 --- a/src/main/java/com/bmilab/backend/domain/research/award/service/AwardService.java +++ b/src/main/java/com/bmilab/backend/domain/research/award/service/AwardService.java @@ -15,6 +15,8 @@ import com.bmilab.backend.domain.research.service.AuthorSyncService; import com.bmilab.backend.domain.task.entity.Task; import com.bmilab.backend.domain.task.repository.TaskRepository; +import com.bmilab.backend.domain.user.entity.User; +import com.bmilab.backend.domain.user.repository.UserRepository; import com.bmilab.backend.global.exception.ApiException; import com.bmilab.backend.global.exception.GlobalErrorCode; import lombok.RequiredArgsConstructor; @@ -35,8 +37,11 @@ public class AwardService { private final ProjectRepository projectRepository; private final TaskRepository taskRepository; private final AuthorSyncService authorSyncService; + private final UserRepository userRepository; - public AwardResponse createAward(CreateAwardRequest dto) { + public AwardResponse createAward(Long userId, CreateAwardRequest dto) { + User creator = userRepository.findById(userId) + .orElseThrow(() -> new ApiException(GlobalErrorCode.GLOBAL_NOT_FOUND)); Project project = projectRepository.findById(dto.projectId()) .orElseThrow(() -> new ApiException(GlobalErrorCode.GLOBAL_NOT_FOUND)); Task task = dto.taskId() != null @@ -51,6 +56,7 @@ public AwardResponse createAward(CreateAwardRequest dto) { .presentationTitle(dto.presentationTitle()) .project(project) .task(task) + .createdBy(creator) .build(); awardRepository.save(newAward); @@ -71,9 +77,9 @@ public AwardResponse createAward(CreateAwardRequest dto) { } public void deleteAward(Long userId, boolean isAdmin, Long awardId) { - if (!isAdmin) { - throw new ApiException(AwardErrorCode.AWARD_ACCESS_DENIED); - } + Award award = awardRepository.findById(awardId) + .orElseThrow(() -> new ApiException(AwardErrorCode.AWARD_NOT_FOUND)); + validateAdminOrCreator(userId, isAdmin, award.getCreatedBy()); awardRecipientRepository.deleteAllByAwardId(awardId); awardRepository.deleteById(awardId); } @@ -86,9 +92,10 @@ public AwardResponse getAward(Long awardId) { return new AwardResponse(award, recipients); } - public AwardResponse updateAward(Long awardId, UpdateAwardRequest dto) { + public AwardResponse updateAward(Long userId, boolean isAdmin, Long awardId, UpdateAwardRequest dto) { Award award = awardRepository.findById(awardId) .orElseThrow(() -> new ApiException(AwardErrorCode.AWARD_NOT_FOUND)); + validateAdminOrCreator(userId, isAdmin, award.getCreatedBy()); Project project = projectRepository.findById(dto.projectId()) .orElseThrow(() -> new ApiException(GlobalErrorCode.GLOBAL_NOT_FOUND)); Task task = dto.taskId() != null @@ -127,4 +134,10 @@ public AwardFindAllResponse getAwards(String keyword, Pageable pageable) { return AwardFindAllResponse.of(awards, awardPage.getTotalPages()); } + + private void validateAdminOrCreator(Long userId, boolean isAdmin, User createdBy) { + if (!isAdmin && (createdBy == null || !createdBy.getId().equals(userId))) { + throw new ApiException(AwardErrorCode.AWARD_ACCESS_DENIED); + } + } } diff --git a/src/main/java/com/bmilab/backend/domain/research/paper/controller/JournalApi.java b/src/main/java/com/bmilab/backend/domain/research/paper/controller/JournalApi.java index 6c4cfe6b..c66f255d 100644 --- a/src/main/java/com/bmilab/backend/domain/research/paper/controller/JournalApi.java +++ b/src/main/java/com/bmilab/backend/domain/research/paper/controller/JournalApi.java @@ -22,9 +22,11 @@ @Tag(name = "Research", description = "연구 실적 관리 API") public interface JournalApi { - @Operation(summary = "저널 생성", description = "새로운 저널을 생성하는 POST API") + @Operation(summary = "저널 생성", description = "새로운 저널을 생성하는 POST API (관리자 전용)") @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "저널 생성 성공"), + @ApiResponse(responseCode = "403", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) @@ -33,9 +35,11 @@ ResponseEntity createJournal( @RequestBody @Valid CreateJournalRequest request ); - @Operation(summary = "저널 수정", description = "저널을 수정하는 PUT API") + @Operation(summary = "저널 수정", description = "저널을 수정하는 PUT API (관리자 전용)") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "저널 수정 성공"), + @ApiResponse(responseCode = "403", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "404", description = "저널을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) diff --git a/src/main/java/com/bmilab/backend/domain/research/paper/controller/JournalController.java b/src/main/java/com/bmilab/backend/domain/research/paper/controller/JournalController.java index 2a0105cf..995881cb 100644 --- a/src/main/java/com/bmilab/backend/domain/research/paper/controller/JournalController.java +++ b/src/main/java/com/bmilab/backend/domain/research/paper/controller/JournalController.java @@ -30,7 +30,8 @@ public ResponseEntity createJournal( @AuthenticationPrincipal UserAuthInfo userAuthInfo, @RequestBody @Valid CreateJournalRequest request ) { - JournalResponse response = journalService.createJournal(request); + boolean isAdmin = userAuthInfo.getUser().getRole() == Role.ADMIN; + JournalResponse response = journalService.createJournal(isAdmin, request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -40,7 +41,8 @@ public ResponseEntity updateJournal( @PathVariable Long journalId, @RequestBody @Valid UpdateJournalRequest request ) { - JournalResponse response = journalService.updateJournal(journalId, request); + boolean isAdmin = userAuthInfo.getUser().getRole() == Role.ADMIN; + JournalResponse response = journalService.updateJournal(isAdmin, journalId, request); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/bmilab/backend/domain/research/paper/controller/PaperController.java b/src/main/java/com/bmilab/backend/domain/research/paper/controller/PaperController.java index fb255b5b..d1bd71df 100644 --- a/src/main/java/com/bmilab/backend/domain/research/paper/controller/PaperController.java +++ b/src/main/java/com/bmilab/backend/domain/research/paper/controller/PaperController.java @@ -30,7 +30,7 @@ public ResponseEntity createPaper( @AuthenticationPrincipal UserAuthInfo userAuthInfo, @RequestBody @Valid CreatePaperRequest request ) { - PaperResponse response = paperService.createPaper(request); + PaperResponse response = paperService.createPaper(userAuthInfo.getUserId(), request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -40,7 +40,8 @@ public ResponseEntity updatePaper( @PathVariable Long paperId, @RequestBody @Valid UpdatePaperRequest request ) { - PaperResponse response = paperService.updatePaper(paperId, request); + boolean isAdmin = userAuthInfo.getUser().getRole() == Role.ADMIN; + PaperResponse response = paperService.updatePaper(userAuthInfo.getUserId(), isAdmin, paperId, request); return ResponseEntity.ok(response); } 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 228812a8..48b7a2d7 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 @@ -3,6 +3,7 @@ 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.domain.user.entity.User; import com.bmilab.backend.global.entity.BaseTimeEntity; import lombok.AccessLevel; import lombok.Builder; @@ -77,8 +78,12 @@ public class Paper extends BaseTimeEntity { @JoinColumn(name = "project_id") private Project project; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + private User createdBy; + @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, Project project) { + 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, User createdBy) { this.acceptDate = acceptDate; this.publishDate = publishDate; this.journal = journal; @@ -97,6 +102,7 @@ public Paper(LocalDate acceptDate, LocalDate publishDate, Journal journal, Strin this.isRepresentative = isRepresentative; this.task = task; this.project = project; + this.createdBy = createdBy; } 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) { diff --git a/src/main/java/com/bmilab/backend/domain/research/paper/service/JournalService.java b/src/main/java/com/bmilab/backend/domain/research/paper/service/JournalService.java index 504bfa01..0753692c 100644 --- a/src/main/java/com/bmilab/backend/domain/research/paper/service/JournalService.java +++ b/src/main/java/com/bmilab/backend/domain/research/paper/service/JournalService.java @@ -24,7 +24,11 @@ public class JournalService { private final JournalRepository journalRepository; - public JournalResponse createJournal(CreateJournalRequest dto) { + public JournalResponse createJournal(boolean isAdmin, CreateJournalRequest dto) { + if (!isAdmin) { + throw new ApiException(PaperErrorCode.PAPER_ACCESS_DENIED); + } + Journal newJournal = Journal.builder() .journalName(dto.journalName()) .year(dto.year()) @@ -56,7 +60,11 @@ public JournalResponse getJournal(Long journalId) { return JournalResponse.from(journal); } - public JournalResponse updateJournal(Long journalId, UpdateJournalRequest dto) { + public JournalResponse updateJournal(boolean isAdmin, Long journalId, UpdateJournalRequest dto) { + if (!isAdmin) { + throw new ApiException(PaperErrorCode.PAPER_ACCESS_DENIED); + } + Journal journal = journalRepository.findById(journalId) .orElseThrow(() -> new ApiException(PaperErrorCode.JOURNAL_NOT_FOUND)); journal.update( 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 0e09388c..6a026b8c 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 @@ -27,6 +27,7 @@ import com.bmilab.backend.domain.task.exception.TaskErrorCode; import com.bmilab.backend.domain.task.repository.TaskRepository; import com.bmilab.backend.domain.user.entity.User; +import com.bmilab.backend.domain.user.repository.UserRepository; import com.bmilab.backend.global.exception.ApiException; import com.bmilab.backend.global.exception.GlobalErrorCode; import lombok.RequiredArgsConstructor; @@ -53,8 +54,11 @@ public class PaperService { private final ProjectRepository projectRepository; private final FileService fileService; private final AuthorSyncService authorSyncService; + private final UserRepository userRepository; - public PaperResponse createPaper(CreatePaperRequest dto) { + public PaperResponse createPaper(Long userId, CreatePaperRequest dto) { + User creator = userRepository.findById(userId) + .orElseThrow(() -> new ApiException(GlobalErrorCode.GLOBAL_NOT_FOUND)); Journal journal = dto.journalId() != null ? journalRepository.findById(dto.journalId()).orElseThrow(() -> new ApiException(PaperErrorCode.JOURNAL_NOT_FOUND)) : null; @@ -84,6 +88,7 @@ public PaperResponse createPaper(CreatePaperRequest dto) { .isRepresentative(dto.isRepresentative()) .task(task) .project(project) + .createdBy(creator) .build(); paperRepository.save(newPaper); @@ -139,9 +144,9 @@ public PaperResponse createPaper(CreatePaperRequest dto) { } public void deletePaper(Long userId, boolean isAdmin, Long paperId) { - if (!isAdmin) { - throw new ApiException(PaperErrorCode.PAPER_ACCESS_DENIED); - } + Paper paper = paperRepository.findById(paperId) + .orElseThrow(() -> new ApiException(PaperErrorCode.PAPER_NOT_FOUND)); + validateAdminOrCreator(userId, isAdmin, paper.getCreatedBy()); paperAuthorRepository.deleteAllByPaperId(paperId); paperCorrespondingAuthorRepository.deleteAllByPaperId(paperId); fileService.deleteAllFileByDomainTypeAndEntityId(FileDomainType.PAPER_ATTACHMENT, paperId); @@ -161,9 +166,10 @@ public PaperResponse getPaper(Long paperId) { return new PaperResponse(paper, correspondingAuthors, paperAuthors, fileSummaries); } - public PaperResponse updatePaper(Long paperId, UpdatePaperRequest dto) { + public PaperResponse updatePaper(Long userId, boolean isAdmin, Long paperId, UpdatePaperRequest dto) { Paper paper = paperRepository.findById(paperId) .orElseThrow(() -> new ApiException(PaperErrorCode.PAPER_NOT_FOUND)); + validateAdminOrCreator(userId, isAdmin, paper.getCreatedBy()); Journal journal = dto.journalId() != null ? journalRepository.findById(dto.journalId()).orElseThrow(() -> new ApiException(PaperErrorCode.JOURNAL_NOT_FOUND)) : null; @@ -251,4 +257,10 @@ public PaperFindAllResponse getPapers(String keyword, Pageable pageable) { return PaperFindAllResponse.of(papers, paperPage.getTotalPages()); } + + private void validateAdminOrCreator(Long userId, boolean isAdmin, User createdBy) { + if (!isAdmin && (createdBy == null || !createdBy.getId().equals(userId))) { + throw new ApiException(PaperErrorCode.PAPER_ACCESS_DENIED); + } + } } diff --git a/src/main/java/com/bmilab/backend/domain/research/patent/controller/PatentController.java b/src/main/java/com/bmilab/backend/domain/research/patent/controller/PatentController.java index ed14df1a..fa0239b2 100644 --- a/src/main/java/com/bmilab/backend/domain/research/patent/controller/PatentController.java +++ b/src/main/java/com/bmilab/backend/domain/research/patent/controller/PatentController.java @@ -30,7 +30,7 @@ public ResponseEntity createPatent( @AuthenticationPrincipal UserAuthInfo userAuthInfo, @RequestBody @Valid CreatePatentRequest request ) { - PatentResponse response = patentService.createPatent(request); + PatentResponse response = patentService.createPatent(userAuthInfo.getUserId(), request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -40,7 +40,8 @@ public ResponseEntity updatePatent( @PathVariable Long patentId, @RequestBody @Valid UpdatePatentRequest request ) { - PatentResponse response = patentService.updatePatent(patentId, request); + boolean isAdmin = userAuthInfo.getUser().getRole() == Role.ADMIN; + PatentResponse response = patentService.updatePatent(userAuthInfo.getUserId(), isAdmin, patentId, request); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/bmilab/backend/domain/research/patent/entity/Patent.java b/src/main/java/com/bmilab/backend/domain/research/patent/entity/Patent.java index 5ef2cdff..2eda8fbe 100644 --- a/src/main/java/com/bmilab/backend/domain/research/patent/entity/Patent.java +++ b/src/main/java/com/bmilab/backend/domain/research/patent/entity/Patent.java @@ -2,6 +2,7 @@ import com.bmilab.backend.domain.project.entity.Project; import com.bmilab.backend.domain.task.entity.Task; +import com.bmilab.backend.domain.user.entity.User; import com.bmilab.backend.global.entity.BaseTimeEntity; import lombok.AccessLevel; import lombok.Builder; @@ -48,8 +49,12 @@ public class Patent extends BaseTimeEntity { @JoinColumn(name = "task_id") private Task task; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + private User createdBy; + @Builder - public Patent(LocalDate applicationDate, String applicationNumber, String patentName, String applicantsAll, String remarks, Project project, Task task) { + public Patent(LocalDate applicationDate, String applicationNumber, String patentName, String applicantsAll, String remarks, Project project, Task task, User createdBy) { this.applicationDate = applicationDate; this.applicationNumber = applicationNumber; this.patentName = patentName; @@ -57,6 +62,7 @@ public Patent(LocalDate applicationDate, String applicationNumber, String patent this.remarks = remarks; this.project = project; this.task = task; + this.createdBy = createdBy; } public void update(LocalDate applicationDate, String applicationNumber, String patentName, String applicantsAll, String remarks, Project project, Task task) { diff --git a/src/main/java/com/bmilab/backend/domain/research/patent/service/PatentService.java b/src/main/java/com/bmilab/backend/domain/research/patent/service/PatentService.java index 425c426f..c6d5b692 100644 --- a/src/main/java/com/bmilab/backend/domain/research/patent/service/PatentService.java +++ b/src/main/java/com/bmilab/backend/domain/research/patent/service/PatentService.java @@ -47,7 +47,9 @@ public class PatentService { private final ExternalProfessorRepository externalProfessorRepository; private final FileService fileService; - public PatentResponse createPatent(CreatePatentRequest dto) { + public PatentResponse createPatent(Long userId, CreatePatentRequest dto) { + User creator = userRepository.findById(userId) + .orElseThrow(() -> new ApiException(GlobalErrorCode.GLOBAL_NOT_FOUND)); Project project = dto.projectId() != null ? projectRepository.findById(dto.projectId()).orElseThrow(() -> new ApiException(GlobalErrorCode.GLOBAL_NOT_FOUND)) : null; @@ -62,6 +64,7 @@ public PatentResponse createPatent(CreatePatentRequest dto) { .remarks(dto.remarks()) .project(project) .task(task) + .createdBy(creator) .build(); patentRepository.save(newPatent); @@ -83,9 +86,9 @@ public PatentResponse createPatent(CreatePatentRequest dto) { } public void deletePatent(Long userId, boolean isAdmin, Long patentId) { - if (!isAdmin) { - throw new ApiException(PatentErrorCode.PATENT_ACCESS_DENIED); - } + Patent patent = patentRepository.findById(patentId) + .orElseThrow(() -> new ApiException(PatentErrorCode.PATENT_NOT_FOUND)); + validateAdminOrCreator(userId, isAdmin, patent.getCreatedBy()); patentAuthorRepository.deleteAllByPatentId(patentId); // Explicitly delete PatentAuthors fileService.deleteAllFileByDomainTypeAndEntityId(FileDomainType.PATENT_ATTACHMENT, patentId); patentRepository.deleteById(patentId); @@ -103,9 +106,10 @@ public PatentResponse getPatent(Long patentId) { return new PatentResponse(patent, patentAuthors, fileSummaries); } - public PatentResponse updatePatent(Long patentId, UpdatePatentRequest dto) { + public PatentResponse updatePatent(Long userId, boolean isAdmin, Long patentId, UpdatePatentRequest dto) { Patent patent = patentRepository.findById(patentId) .orElseThrow(() -> new ApiException(PatentErrorCode.PATENT_NOT_FOUND)); + validateAdminOrCreator(userId, isAdmin, patent.getCreatedBy()); Project project = dto.projectId() != null ? projectRepository.findById(dto.projectId()).orElseThrow(() -> new ApiException(GlobalErrorCode.GLOBAL_NOT_FOUND)) : null; @@ -216,4 +220,9 @@ private List syncPatentAuthors( return patentAuthors; } + private void validateAdminOrCreator(Long userId, boolean isAdmin, User createdBy) { + if (!isAdmin && (createdBy == null || !createdBy.getId().equals(userId))) { + throw new ApiException(PatentErrorCode.PATENT_ACCESS_DENIED); + } + } } diff --git a/src/main/java/com/bmilab/backend/domain/research/presentation/controller/PresentationController.java b/src/main/java/com/bmilab/backend/domain/research/presentation/controller/PresentationController.java index 6a55455c..1d3b5253 100644 --- a/src/main/java/com/bmilab/backend/domain/research/presentation/controller/PresentationController.java +++ b/src/main/java/com/bmilab/backend/domain/research/presentation/controller/PresentationController.java @@ -30,7 +30,7 @@ public ResponseEntity createAcademicPresentation( @AuthenticationPrincipal UserAuthInfo userAuthInfo, @RequestBody @Valid CreateAcademicPresentationRequest request ) { - AcademicPresentationResponse response = presentationService.createAcademicPresentation(request); + AcademicPresentationResponse response = presentationService.createAcademicPresentation(userAuthInfo.getUserId(), request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -40,7 +40,8 @@ public ResponseEntity updateAcademicPresentation( @PathVariable Long academicPresentationId, @RequestBody @Valid UpdateAcademicPresentationRequest request ) { - AcademicPresentationResponse response = presentationService.updateAcademicPresentation(academicPresentationId, request); + boolean isAdmin = userAuthInfo.getUser().getRole() == Role.ADMIN; + AcademicPresentationResponse response = presentationService.updateAcademicPresentation(userAuthInfo.getUserId(), isAdmin, academicPresentationId, request); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/bmilab/backend/domain/research/presentation/entity/AcademicPresentation.java b/src/main/java/com/bmilab/backend/domain/research/presentation/entity/AcademicPresentation.java index a4d9dc80..4add598c 100644 --- a/src/main/java/com/bmilab/backend/domain/research/presentation/entity/AcademicPresentation.java +++ b/src/main/java/com/bmilab/backend/domain/research/presentation/entity/AcademicPresentation.java @@ -3,6 +3,7 @@ import com.bmilab.backend.domain.project.entity.Project; import com.bmilab.backend.domain.research.presentation.enums.AcademicPresentationType; import com.bmilab.backend.domain.task.entity.Task; +import com.bmilab.backend.domain.user.entity.User; import com.bmilab.backend.global.entity.BaseTimeEntity; import lombok.AccessLevel; import lombok.Builder; @@ -55,8 +56,12 @@ public class AcademicPresentation extends BaseTimeEntity { @JoinColumn(name = "task_id") private Task task; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + private User createdBy; + @Builder - public AcademicPresentation(String authors, LocalDate academicPresentationStartDate, LocalDate academicPresentationEndDate, String academicPresentationLocation, String academicPresentationHost, String academicPresentationName, AcademicPresentationType presentationType, String presentationTitle, Project project, Task task) { + public AcademicPresentation(String authors, LocalDate academicPresentationStartDate, LocalDate academicPresentationEndDate, String academicPresentationLocation, String academicPresentationHost, String academicPresentationName, AcademicPresentationType presentationType, String presentationTitle, Project project, Task task, User createdBy) { this.authors = authors; this.academicPresentationStartDate = academicPresentationStartDate; this.academicPresentationEndDate = academicPresentationEndDate; @@ -67,6 +72,7 @@ public AcademicPresentation(String authors, LocalDate academicPresentationStartD this.presentationTitle = presentationTitle; this.project = project; this.task = task; + this.createdBy = createdBy; } public void update(String authors, LocalDate academicPresentationStartDate, LocalDate academicPresentationEndDate, String academicPresentationLocation, String academicPresentationHost, String academicPresentationName, AcademicPresentationType presentationType, String presentationTitle, Project project, Task task) { diff --git a/src/main/java/com/bmilab/backend/domain/research/presentation/service/PresentationService.java b/src/main/java/com/bmilab/backend/domain/research/presentation/service/PresentationService.java index d4ba0aa4..fde77918 100644 --- a/src/main/java/com/bmilab/backend/domain/research/presentation/service/PresentationService.java +++ b/src/main/java/com/bmilab/backend/domain/research/presentation/service/PresentationService.java @@ -15,6 +15,8 @@ import com.bmilab.backend.domain.research.service.AuthorSyncService; import com.bmilab.backend.domain.task.entity.Task; import com.bmilab.backend.domain.task.repository.TaskRepository; +import com.bmilab.backend.domain.user.entity.User; +import com.bmilab.backend.domain.user.repository.UserRepository; import com.bmilab.backend.global.exception.ApiException; import com.bmilab.backend.global.exception.GlobalErrorCode; import lombok.RequiredArgsConstructor; @@ -35,8 +37,11 @@ public class PresentationService { private final ProjectRepository projectRepository; private final TaskRepository taskRepository; private final AuthorSyncService authorSyncService; + private final UserRepository userRepository; - public AcademicPresentationResponse createAcademicPresentation(CreateAcademicPresentationRequest dto) { + public AcademicPresentationResponse createAcademicPresentation(Long userId, CreateAcademicPresentationRequest dto) { + User creator = userRepository.findById(userId) + .orElseThrow(() -> new ApiException(GlobalErrorCode.GLOBAL_NOT_FOUND)); Project project = projectRepository.findById(dto.projectId()) .orElseThrow(() -> new ApiException(GlobalErrorCode.GLOBAL_NOT_FOUND)); Task task = null; @@ -55,6 +60,7 @@ public AcademicPresentationResponse createAcademicPresentation(CreateAcademicPre .presentationTitle(dto.presentationTitle()) .project(project) .task(task) + .createdBy(creator) .build(); academicPresentationRepository.save(newAcademicPresentation); @@ -75,9 +81,9 @@ public AcademicPresentationResponse createAcademicPresentation(CreateAcademicPre } public void deleteAcademicPresentation(Long userId, boolean isAdmin, Long academicPresentationId) { - if (!isAdmin) { - throw new ApiException(PresentationErrorCode.ACADEMIC_PRESENTATION_ACCESS_DENIED); - } + AcademicPresentation presentation = academicPresentationRepository.findById(academicPresentationId) + .orElseThrow(() -> new ApiException(PresentationErrorCode.ACADEMIC_PRESENTATION_NOT_FOUND)); + validateAdminOrCreator(userId, isAdmin, presentation.getCreatedBy()); academicPresentationAuthorRepository.deleteAllByAcademicPresentationId(academicPresentationId); academicPresentationRepository.deleteById(academicPresentationId); } @@ -90,9 +96,10 @@ public AcademicPresentationResponse getAcademicPresentation(Long academicPresent return new AcademicPresentationResponse(academicPresentation, authors); } - public AcademicPresentationResponse updateAcademicPresentation(Long academicPresentationId, UpdateAcademicPresentationRequest dto) { + public AcademicPresentationResponse updateAcademicPresentation(Long userId, boolean isAdmin, Long academicPresentationId, UpdateAcademicPresentationRequest dto) { AcademicPresentation academicPresentation = academicPresentationRepository.findById(academicPresentationId) .orElseThrow(() -> new ApiException(PresentationErrorCode.ACADEMIC_PRESENTATION_NOT_FOUND)); + validateAdminOrCreator(userId, isAdmin, academicPresentation.getCreatedBy()); Project project = projectRepository.findById(dto.projectId()) .orElseThrow(() -> new ApiException(GlobalErrorCode.GLOBAL_NOT_FOUND)); Task task = null; @@ -133,4 +140,10 @@ public AcademicPresentationFindAllResponse getAcademicPresentations(String keywo return AcademicPresentationFindAllResponse.of(presentations, presentationPage.getTotalPages()); } + + private void validateAdminOrCreator(Long userId, boolean isAdmin, User createdBy) { + if (!isAdmin && (createdBy == null || !createdBy.getId().equals(userId))) { + throw new ApiException(PresentationErrorCode.ACADEMIC_PRESENTATION_ACCESS_DENIED); + } + } } diff --git a/src/main/java/com/bmilab/backend/domain/research/publication/controller/PublicationController.java b/src/main/java/com/bmilab/backend/domain/research/publication/controller/PublicationController.java index 0f5e26d4..60cc5148 100644 --- a/src/main/java/com/bmilab/backend/domain/research/publication/controller/PublicationController.java +++ b/src/main/java/com/bmilab/backend/domain/research/publication/controller/PublicationController.java @@ -30,7 +30,7 @@ public ResponseEntity createPublication( @AuthenticationPrincipal UserAuthInfo userAuthInfo, @RequestBody @Valid CreateAuthorRequest request ) { - AuthorResponse response = publicationService.createPublication(request); + AuthorResponse response = publicationService.createPublication(userAuthInfo.getUserId(), request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -40,7 +40,8 @@ public ResponseEntity updatePublication( @PathVariable Long authorId, @RequestBody @Valid UpdateAuthorRequest request ) { - AuthorResponse response = publicationService.updatePublication(authorId, request); + boolean isAdmin = userAuthInfo.getUser().getRole() == Role.ADMIN; + AuthorResponse response = publicationService.updatePublication(userAuthInfo.getUserId(), isAdmin, authorId, request); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/bmilab/backend/domain/research/publication/entity/Author.java b/src/main/java/com/bmilab/backend/domain/research/publication/entity/Author.java index 06e559d2..7a0159de 100644 --- a/src/main/java/com/bmilab/backend/domain/research/publication/entity/Author.java +++ b/src/main/java/com/bmilab/backend/domain/research/publication/entity/Author.java @@ -1,6 +1,7 @@ package com.bmilab.backend.domain.research.publication.entity; import com.bmilab.backend.domain.research.publication.enums.AuthorType; +import com.bmilab.backend.domain.user.entity.User; import com.bmilab.backend.global.entity.BaseTimeEntity; import lombok.AccessLevel; import lombok.Builder; @@ -45,8 +46,12 @@ public class Author extends BaseTimeEntity { @Column(nullable = false) private String isbn; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + private User createdBy; + @Builder - public Author(String authors, AuthorType authorType, LocalDate publicationDate, String publicationHouse, String publisher, String publicationName, String title, String isbn) { + public Author(String authors, AuthorType authorType, LocalDate publicationDate, String publicationHouse, String publisher, String publicationName, String title, String isbn, User createdBy) { this.authors = authors; this.authorType = authorType; this.publicationDate = publicationDate; @@ -55,6 +60,7 @@ public Author(String authors, AuthorType authorType, LocalDate publicationDate, this.publicationName = publicationName; this.title = title; this.isbn = isbn; + this.createdBy = createdBy; } public void update(String authors, AuthorType authorType, LocalDate publicationDate, String publicationHouse, String publisher, String publicationName, String title, String isbn) { diff --git a/src/main/java/com/bmilab/backend/domain/research/publication/service/PublicationService.java b/src/main/java/com/bmilab/backend/domain/research/publication/service/PublicationService.java index a995bb44..37e0cdef 100644 --- a/src/main/java/com/bmilab/backend/domain/research/publication/service/PublicationService.java +++ b/src/main/java/com/bmilab/backend/domain/research/publication/service/PublicationService.java @@ -8,7 +8,10 @@ import com.bmilab.backend.domain.research.publication.entity.Author; import com.bmilab.backend.domain.research.publication.repository.AuthorRepository; import com.bmilab.backend.domain.research.publication.exception.PublicationErrorCode; +import com.bmilab.backend.domain.user.entity.User; +import com.bmilab.backend.domain.user.repository.UserRepository; import com.bmilab.backend.global.exception.ApiException; +import com.bmilab.backend.global.exception.GlobalErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.domain.Page; @@ -23,8 +26,11 @@ public class PublicationService { @Qualifier("researchAuthorRepository") private final AuthorRepository authorRepository; + private final UserRepository userRepository; - public AuthorResponse createPublication(CreateAuthorRequest dto) { + public AuthorResponse createPublication(Long userId, CreateAuthorRequest dto) { + User creator = userRepository.findById(userId) + .orElseThrow(() -> new ApiException(GlobalErrorCode.GLOBAL_NOT_FOUND)); Author newAuthor = Author.builder() .authors(dto.authors()) .authorType(dto.authorType()) @@ -34,15 +40,16 @@ public AuthorResponse createPublication(CreateAuthorRequest dto) { .publicationName(dto.publicationName()) .title(dto.title()) .isbn(dto.isbn()) + .createdBy(creator) .build(); authorRepository.save(newAuthor); return new AuthorResponse(newAuthor); } public void deletePublication(Long userId, boolean isAdmin, Long publicationId) { - if (!isAdmin) { - throw new ApiException(PublicationErrorCode.PUBLICATION_ACCESS_DENIED); - } + Author author = authorRepository.findById(publicationId) + .orElseThrow(() -> new ApiException(PublicationErrorCode.PUBLICATION_NOT_FOUND)); + validateAdminOrCreator(userId, isAdmin, author.getCreatedBy()); authorRepository.deleteById(publicationId); } @@ -52,9 +59,10 @@ public AuthorResponse getPublication(Long publicationId) { return new AuthorResponse(author); } - public AuthorResponse updatePublication(Long publicationId, UpdateAuthorRequest dto) { + public AuthorResponse updatePublication(Long userId, boolean isAdmin, Long publicationId, UpdateAuthorRequest dto) { Author author = authorRepository.findById(publicationId) .orElseThrow(() -> new ApiException(PublicationErrorCode.PUBLICATION_NOT_FOUND)); + validateAdminOrCreator(userId, isAdmin, author.getCreatedBy()); author.update(dto.authors(), dto.authorType(), dto.publicationDate(), dto.publicationHouse(), dto.publisher(), dto.publicationName(), dto.title(), dto.isbn()); return new AuthorResponse(author); } @@ -65,4 +73,10 @@ public AuthorFindAllResponse getPublications(String keyword, Pageable pageable) Page responsePage = authorPage.map(AuthorSummaryResponse::from); return AuthorFindAllResponse.from(responsePage); } + + private void validateAdminOrCreator(Long userId, boolean isAdmin, User createdBy) { + if (!isAdmin && (createdBy == null || !createdBy.getId().equals(userId))) { + throw new ApiException(PublicationErrorCode.PUBLICATION_ACCESS_DENIED); + } + } } 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 index 605f63e1..94e3fd32 100644 --- a/src/main/java/com/bmilab/backend/domain/seminar/controller/SeminarApi.java +++ b/src/main/java/com/bmilab/backend/domain/seminar/controller/SeminarApi.java @@ -2,6 +2,7 @@ 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.CreateSeminarResponse; 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; @@ -51,11 +52,12 @@ ResponseEntity searchSeminars( }) ResponseEntity getSeminarById(@PathVariable Long seminarId); - @Operation(summary = "세미나/학회 일정 생성", description = "새로운 세미나/학회 일정을 생성하는 POST API") + @Operation(summary = "세미나/학회 일정 생성", description = "새로운 세미나/학회 일정을 생성하는 POST API. repeatType 지정 시 반복 일정을 일괄 생성합니다.") @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "생성 성공") + @ApiResponse(responseCode = "201", description = "생성 성공"), + @ApiResponse(responseCode = "400", description = "반복 옵션이 올바르지 않습니다.") }) - ResponseEntity createSeminar( + ResponseEntity createSeminar( @AuthenticationPrincipal UserAuthInfo userAuthInfo, @RequestBody CreateSeminarRequest request ); @@ -63,7 +65,6 @@ ResponseEntity createSeminar( @Operation(summary = "세미나/학회 일정 수정", description = "세미나/학회 일정을 수정하는 PUT API") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "수정 성공"), - @ApiResponse(responseCode = "403", description = "수정/삭제 권한이 없습니다."), @ApiResponse(responseCode = "404", description = "세미나/학회 일정을 찾을 수 없습니다.") }) ResponseEntity updateSeminar( @@ -75,7 +76,6 @@ ResponseEntity updateSeminar( @Operation(summary = "세미나/학회 일정 삭제", description = "세미나/학회 일정을 삭제하는 DELETE API") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "삭제 성공"), - @ApiResponse(responseCode = "403", description = "수정/삭제 권한이 없습니다."), @ApiResponse(responseCode = "404", description = "세미나/학회 일정을 찾을 수 없습니다.") }) ResponseEntity deleteSeminar( 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 index aa69bfb3..b647ba81 100644 --- a/src/main/java/com/bmilab/backend/domain/seminar/controller/SeminarController.java +++ b/src/main/java/com/bmilab/backend/domain/seminar/controller/SeminarController.java @@ -2,6 +2,7 @@ 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.CreateSeminarResponse; 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; @@ -9,6 +10,7 @@ import com.bmilab.backend.global.security.UserAuthInfo; import jakarta.validation.Valid; import java.time.LocalDate; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Pageable; @@ -61,12 +63,12 @@ public ResponseEntity getSeminarById(@PathVariable Long seminar @Override @PostMapping - public ResponseEntity createSeminar( + public ResponseEntity createSeminar( @AuthenticationPrincipal UserAuthInfo userAuthInfo, @RequestBody @Valid CreateSeminarRequest request ) { - seminarService.createSeminar(userAuthInfo.getUserId(), request); - return ResponseEntity.status(HttpStatus.CREATED).build(); + List seminarIds = seminarService.createSeminar(userAuthInfo.getUserId(), request); + return ResponseEntity.status(HttpStatus.CREATED).body(CreateSeminarResponse.from(seminarIds)); } @Override @@ -76,7 +78,7 @@ public ResponseEntity updateSeminar( @PathVariable Long seminarId, @RequestBody @Valid UpdateSeminarRequest request ) { - seminarService.updateSeminar(userAuthInfo.getUserId(), seminarId, request); + seminarService.updateSeminar(seminarId, request); return ResponseEntity.ok().build(); } @@ -86,7 +88,7 @@ public ResponseEntity deleteSeminar( @AuthenticationPrincipal UserAuthInfo userAuthInfo, @PathVariable Long seminarId ) { - seminarService.deleteSeminar(userAuthInfo.getUserId(), seminarId); + seminarService.deleteSeminar(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 index 0ec37e23..b57b0253 100644 --- 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 @@ -1,10 +1,12 @@ package com.bmilab.backend.domain.seminar.dto.request; +import com.bmilab.backend.domain.seminar.enums.RepeatType; 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; +import java.time.LocalTime; public record CreateSeminarRequest( @Schema(description = "라벨 (SEMINAR: 세미나, CONFERENCE: 학회)", example = "CONFERENCE") @@ -22,6 +24,18 @@ public record CreateSeminarRequest( @Schema(description = "종료일 (선택)", example = "2025-03-17") LocalDate endDate, + @Schema(description = "시작 시간 (선택, 없으면 종일 이벤트)", example = "14:00") + LocalTime startTime, + + @Schema(description = "종료 시간 (선택)", example = "16:00") + LocalTime endTime, + @Schema(description = "발표/준비/참석 메모 (선택)", example = "발표 예정") - String note + String note, + + @Schema(description = "반복 유형 (WEEKLY: 매주, MONTHLY: 매월, null이면 단건 생성)", example = "WEEKLY") + RepeatType repeatType, + + @Schema(description = "반복 종료일 (repeatType이 있을 때 필수)", example = "2025-06-30") + LocalDate repeatEndDate ) {} 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 index 2c4b9146..11773757 100644 --- 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 @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.time.LocalDate; +import java.time.LocalTime; public record UpdateSeminarRequest( @Schema(description = "라벨 (SEMINAR: 세미나, CONFERENCE: 학회)", example = "CONFERENCE") @@ -22,6 +23,12 @@ public record UpdateSeminarRequest( @Schema(description = "종료일 (선택)", example = "2025-03-17") LocalDate endDate, + @Schema(description = "시작 시간 (선택, 없으면 종일 이벤트)", example = "14:00") + LocalTime startTime, + + @Schema(description = "종료 시간 (선택)", example = "16:00") + LocalTime endTime, + @Schema(description = "기타 메모 (선택)", example = "발표 예정") String note ) {} diff --git a/src/main/java/com/bmilab/backend/domain/seminar/dto/response/CreateSeminarResponse.java b/src/main/java/com/bmilab/backend/domain/seminar/dto/response/CreateSeminarResponse.java new file mode 100644 index 00000000..6780903f --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/dto/response/CreateSeminarResponse.java @@ -0,0 +1,13 @@ +package com.bmilab.backend.domain.seminar.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record CreateSeminarResponse( + @Schema(description = "생성된 세미나 ID 목록") + List seminarIds +) { + public static CreateSeminarResponse from(List ids) { + return new CreateSeminarResponse(ids); + } +} 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 index c47cfa70..754b73e6 100644 --- 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 @@ -3,8 +3,12 @@ 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.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; public record SeminarResponse( @Schema(description = "세미나/학회 ID") @@ -22,6 +26,12 @@ public record SeminarResponse( @Schema(description = "종료일") LocalDate endDate, + @Schema(description = "시작 시간") + LocalTime startTime, + + @Schema(description = "종료 시간") + LocalTime endTime, + @Schema(description = "기타 메모") String note, @@ -32,19 +42,54 @@ public record SeminarResponse( String userName, @Schema(description = "생성일시") - LocalDateTime createdAt + LocalDateTime createdAt, + + @Schema(description = "Google 캘린더 추가 링크") + String googleCalendarLink ) { public static SeminarResponse from(Seminar seminar) { + String eventTitle = "[" + seminar.getLabel().getDescription() + "] " + seminar.getTitle(); return new SeminarResponse( seminar.getId(), seminar.getLabel(), seminar.getTitle(), seminar.getStartDate(), seminar.getEndDate(), + seminar.getStartTime(), + seminar.getEndTime(), seminar.getNote(), seminar.getUser().getId(), seminar.getUser().getName(), - seminar.getCreatedAt() + seminar.getCreatedAt(), + buildGoogleCalendarLink(eventTitle, seminar.getStartDate(), seminar.getEndDate(), + seminar.getStartTime(), seminar.getEndTime()) ); } + + private static String buildGoogleCalendarLink(String title, LocalDate startDate, LocalDate endDate, + LocalTime startTime, LocalTime endTime) { + String encodedTitle = URLEncoder.encode(title, StandardCharsets.UTF_8); + String dates; + + if (startTime != null) { + // AIDEV-NOTE: 시간이 있는 이벤트 — yyyyMMdd'T'HHmmss 형식 + DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"); + String start = startDate.atTime(startTime).format(dtf); + LocalTime effectiveEndTime = endTime != null ? endTime : startTime.plusHours(1); + LocalDate effectiveEndDate = endDate != null ? endDate : startDate; + String end = effectiveEndDate.atTime(effectiveEndTime).format(dtf); + dates = start + "/" + end; + } else { + // AIDEV-NOTE: 종일 이벤트 — yyyyMMdd 형식, endDate는 exclusive이므로 +1일 + DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyyMMdd"); + String start = startDate.format(df); + LocalDate effectiveEnd = (endDate != null ? endDate : startDate).plusDays(1); + String end = effectiveEnd.format(df); + dates = start + "/" + end; + } + + return "https://calendar.google.com/calendar/render?action=TEMPLATE" + + "&text=" + encodedTitle + + "&dates=" + dates; + } } 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 index c26765d7..dc3602fd 100644 --- a/src/main/java/com/bmilab/backend/domain/seminar/entity/Seminar.java +++ b/src/main/java/com/bmilab/backend/domain/seminar/entity/Seminar.java @@ -14,6 +14,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.LocalDate; +import java.time.LocalTime; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -52,14 +53,30 @@ public class Seminar extends BaseTimeEntity { @Column(name = "end_date") private LocalDate endDate; + @Column(name = "start_time") + private LocalTime startTime; + + @Column(name = "end_time") + private LocalTime endTime; + @Column(name = "note", length = 1000) private String note; - public void update(SeminarLabel label, String title, LocalDate startDate, LocalDate endDate, String note) { + @Column(name = "google_event_id") + private String googleEventId; + + public void updateGoogleEventId(String googleEventId) { + this.googleEventId = googleEventId; + } + + public void update(SeminarLabel label, String title, LocalDate startDate, LocalDate endDate, + LocalTime startTime, LocalTime endTime, String note) { this.label = label; this.title = title; this.startDate = startDate; this.endDate = endDate; + this.startTime = startTime; + this.endTime = endTime; this.note = note; } } diff --git a/src/main/java/com/bmilab/backend/domain/seminar/enums/RepeatType.java b/src/main/java/com/bmilab/backend/domain/seminar/enums/RepeatType.java new file mode 100644 index 00000000..7be50fa9 --- /dev/null +++ b/src/main/java/com/bmilab/backend/domain/seminar/enums/RepeatType.java @@ -0,0 +1,13 @@ +package com.bmilab.backend.domain.seminar.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum RepeatType { + WEEKLY("매주"), + MONTHLY("매월"); + + 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 index d09d5940..6b11883f 100644 --- a/src/main/java/com/bmilab/backend/domain/seminar/exception/SeminarErrorCode.java +++ b/src/main/java/com/bmilab/backend/domain/seminar/exception/SeminarErrorCode.java @@ -10,6 +10,7 @@ public enum SeminarErrorCode implements ErrorCode { SEMINAR_NOT_FOUND("세미나/학회 일정을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), ACCESS_DENIED("수정/삭제 권한이 없습니다.", HttpStatus.FORBIDDEN), + INVALID_REPEAT_OPTIONS("반복 종료일은 반복 유형이 지정된 경우 필수이며, 시작일 이후여야 합니다.", HttpStatus.BAD_REQUEST), ; private final String message; 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 index 8256d587..eceac046 100644 --- a/src/main/java/com/bmilab/backend/domain/seminar/service/SeminarService.java +++ b/src/main/java/com/bmilab/backend/domain/seminar/service/SeminarService.java @@ -5,13 +5,18 @@ 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.RepeatType; 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.config.GoogleCalendarConfig; import com.bmilab.backend.global.exception.ApiException; +import com.bmilab.backend.global.external.calendar.GoogleCalendarService; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -25,6 +30,8 @@ public class SeminarService { private final SeminarRepository seminarRepository; private final UserService userService; + private final GoogleCalendarService googleCalendarService; + private final GoogleCalendarConfig googleCalendarConfig; public SeminarFindAllResponse getSeminarsByDateRange(LocalDate startDate, LocalDate endDate) { List seminars = seminarRepository.findAllByDateRange(startDate, endDate); @@ -48,39 +55,108 @@ public SeminarResponse getSeminarById(Long seminarId) { } @Transactional - public Long createSeminar(Long userId, CreateSeminarRequest request) { + public List createSeminar(Long userId, CreateSeminarRequest request) { User user = userService.findUserById(userId); + validateRepeatOptions(request); - Seminar seminar = Seminar.builder() - .user(user) - .label(request.label()) - .title(request.title()) - .startDate(request.startDate()) - .endDate(request.endDate()) - .note(request.note()) - .build(); + List occurrenceDates = calculateOccurrenceDates( + request.startDate(), request.repeatType(), request.repeatEndDate() + ); + + // AIDEV-NOTE: endDate가 있으면 startDate와의 차이를 유지하여 각 반복 일정에 동일한 기간 적용 + Long daysBetween = (request.endDate() != null) + ? ChronoUnit.DAYS.between(request.startDate(), request.endDate()) + : null; + + List seminarIds = new ArrayList<>(); + + for (LocalDate occurrenceDate : occurrenceDates) { + LocalDate occurrenceEndDate = (daysBetween != null) + ? occurrenceDate.plusDays(daysBetween) + : null; + + Seminar seminar = Seminar.builder() + .user(user) + .label(request.label()) + .title(request.title()) + .startDate(occurrenceDate) + .endDate(occurrenceEndDate) + .startTime(request.startTime()) + .endTime(request.endTime()) + .note(request.note()) + .build(); + + seminarRepository.save(seminar); + + if (googleCalendarService.isEnabled()) { + String eventTitle = buildSeminarEventTitle(request.label(), request.title()); + String eventId = googleCalendarService.createEvent( + googleCalendarConfig.getSeminarCalendarId(), + eventTitle, + occurrenceDate, + occurrenceEndDate, + request.startTime(), + request.endTime() + ); + seminar.updateGoogleEventId(eventId); + } - return seminarRepository.save(seminar).getId(); + seminarIds.add(seminar.getId()); + } + + return seminarIds; } @Transactional - public void updateSeminar(Long userId, Long seminarId, UpdateSeminarRequest request) { + public void updateSeminar(Long seminarId, UpdateSeminarRequest request) { Seminar seminar = getSeminar(seminarId); - validateOwnerOrAdmin(userId, seminar); seminar.update( request.label(), request.title(), request.startDate(), request.endDate(), + request.startTime(), + request.endTime(), request.note() ); + + if (googleCalendarService.isEnabled()) { + String eventTitle = buildSeminarEventTitle(request.label(), request.title()); + if (seminar.getGoogleEventId() != null) { + googleCalendarService.updateEvent( + googleCalendarConfig.getSeminarCalendarId(), + seminar.getGoogleEventId(), + eventTitle, + request.startDate(), + request.endDate(), + request.startTime(), + request.endTime() + ); + } else { + String eventId = googleCalendarService.createEvent( + googleCalendarConfig.getSeminarCalendarId(), + eventTitle, + request.startDate(), + request.endDate(), + request.startTime(), + request.endTime() + ); + seminar.updateGoogleEventId(eventId); + } + } } @Transactional - public void deleteSeminar(Long userId, Long seminarId) { + public void deleteSeminar(Long seminarId) { Seminar seminar = getSeminar(seminarId); - validateOwnerOrAdmin(userId, seminar); + + if (googleCalendarService.isEnabled() && seminar.getGoogleEventId() != null) { + googleCalendarService.deleteEvent( + googleCalendarConfig.getSeminarCalendarId(), + seminar.getGoogleEventId() + ); + } seminarRepository.delete(seminar); } @@ -90,10 +166,38 @@ private Seminar getSeminar(Long 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); + private String buildSeminarEventTitle(SeminarLabel label, String title) { + return "[" + label.getDescription() + "] " + title; + } + + private void validateRepeatOptions(CreateSeminarRequest request) { + if (request.repeatType() != null && request.repeatEndDate() == null) { + throw new ApiException(SeminarErrorCode.INVALID_REPEAT_OPTIONS); + } + if (request.repeatType() == null && request.repeatEndDate() != null) { + throw new ApiException(SeminarErrorCode.INVALID_REPEAT_OPTIONS); + } + if (request.repeatType() != null && !request.repeatEndDate().isAfter(request.startDate())) { + throw new ApiException(SeminarErrorCode.INVALID_REPEAT_OPTIONS); + } + } + + private List calculateOccurrenceDates(LocalDate startDate, RepeatType repeatType, LocalDate repeatEndDate) { + if (repeatType == null) { + return List.of(startDate); + } + + List dates = new ArrayList<>(); + LocalDate current = startDate; + + while (!current.isAfter(repeatEndDate)) { + dates.add(current); + current = switch (repeatType) { + case WEEKLY -> current.plusWeeks(1); + case MONTHLY -> current.plusMonths(1); + }; } + + return dates; } } diff --git a/src/main/java/com/bmilab/backend/domain/task/entity/Task.java b/src/main/java/com/bmilab/backend/domain/task/entity/Task.java index 07280508..39e50841 100644 --- a/src/main/java/com/bmilab/backend/domain/task/entity/Task.java +++ b/src/main/java/com/bmilab/backend/domain/task/entity/Task.java @@ -149,15 +149,13 @@ public void update( this.isInternal = isInternal; } - // 제안서 탈락, 발표 탈락, 과제 종료 상태에서는 실무책임자 또는 어드민만 수정 가능 + // 어드민이거나 실무책임자인 경우 수정 가능 public boolean canBeEditedByUser(Long userId, boolean isAdmin) { - if (this.status != TaskStatus.PROPOSAL_REJECTED - && this.status != TaskStatus.PRESENTATION_REJECTED - && this.status != TaskStatus.COMPLETED) { - return true; - } - - // 어드민이거나 실무책임자인 경우 수정 가능 return isAdmin || (this.practicalManager != null && this.practicalManager.getId().equals(userId)); } + + // 삭제는 어드민만 가능 + public boolean canBeDeletedByUser(boolean isAdmin) { + return isAdmin; + } } \ No newline at end of file diff --git a/src/main/java/com/bmilab/backend/domain/task/exception/TaskErrorCode.java b/src/main/java/com/bmilab/backend/domain/task/exception/TaskErrorCode.java index ee23c01e..5043a53f 100644 --- a/src/main/java/com/bmilab/backend/domain/task/exception/TaskErrorCode.java +++ b/src/main/java/com/bmilab/backend/domain/task/exception/TaskErrorCode.java @@ -11,7 +11,8 @@ public enum TaskErrorCode implements ErrorCode { TASK_NOT_FOUND("과제를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), TASK_DUPLICATE_RESEARCH_NUMBER("이미 존재하는 연구과제번호입니다.", HttpStatus.CONFLICT), TASK_INVALID_YEAR("현재 연차는 총 연차보다 클 수 없습니다.", HttpStatus.BAD_REQUEST), - TASK_CANNOT_EDIT("현재 상태에서는 과제를 수정할 수 없습니다.", HttpStatus.FORBIDDEN), + TASK_CANNOT_EDIT("과제 수정 권한이 없습니다.", HttpStatus.FORBIDDEN), + TASK_CANNOT_DELETE("과제 삭제 권한이 없습니다.", HttpStatus.FORBIDDEN), TASK_PERIOD_NOT_FOUND("과제 기간 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), TASK_FILE_NOT_FOUND("과제 파일을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), TASK_MEMBER_NOT_FOUND("과제 참여자 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), diff --git a/src/main/java/com/bmilab/backend/domain/task/service/TaskService.java b/src/main/java/com/bmilab/backend/domain/task/service/TaskService.java index 5bb7ce8d..170fdd84 100644 --- a/src/main/java/com/bmilab/backend/domain/task/service/TaskService.java +++ b/src/main/java/com/bmilab/backend/domain/task/service/TaskService.java @@ -643,8 +643,8 @@ public void deleteTask(Long userId, boolean isAdmin, Long taskId) { Task task = getTaskById(taskId); - if (!task.canBeEditedByUser(userId, isAdmin)) { - throw new ApiException(TaskErrorCode.TASK_CANNOT_EDIT); + if (!task.canBeDeletedByUser(isAdmin)) { + throw new ApiException(TaskErrorCode.TASK_CANNOT_DELETE); } taskRepository.delete(task); diff --git a/src/main/java/com/bmilab/backend/global/config/GoogleCalendarConfig.java b/src/main/java/com/bmilab/backend/global/config/GoogleCalendarConfig.java new file mode 100644 index 00000000..f3f20b98 --- /dev/null +++ b/src/main/java/com/bmilab/backend/global/config/GoogleCalendarConfig.java @@ -0,0 +1,54 @@ +package com.bmilab.backend.global.config; + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.calendar.Calendar; +import com.google.api.services.calendar.CalendarScopes; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.List; + +@Slf4j +@Getter +@Configuration +public class GoogleCalendarConfig { + + @Value("${google.calendar.service-account-key:}") + private String serviceAccountKey; + + @Value("${google.calendar.seminar-calendar-id:}") + private String seminarCalendarId; + + @Value("${google.calendar.leave-calendar-id:}") + private String leaveCalendarId; + + @Bean + public Calendar googleCalendar() throws GeneralSecurityException, IOException { + if (serviceAccountKey == null || serviceAccountKey.isBlank()) { + log.info("Google Calendar 서비스 계정 키가 설정되지 않아 Calendar Bean을 생성하지 않습니다."); + return null; + } + + GoogleCredentials credentials = GoogleCredentials + .fromStream(new ByteArrayInputStream(serviceAccountKey.getBytes(StandardCharsets.UTF_8))) + .createScoped(List.of(CalendarScopes.CALENDAR)); + + log.info("Google Calendar Bean 생성 완료"); + + return new Calendar.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(credentials) + ).setApplicationName("BMI Lab Web Server").build(); + } +} diff --git a/src/main/java/com/bmilab/backend/global/external/calendar/GoogleCalendarService.java b/src/main/java/com/bmilab/backend/global/external/calendar/GoogleCalendarService.java new file mode 100644 index 00000000..40b56e09 --- /dev/null +++ b/src/main/java/com/bmilab/backend/global/external/calendar/GoogleCalendarService.java @@ -0,0 +1,108 @@ +package com.bmilab.backend.global.external.calendar; + +import com.google.api.client.util.DateTime; +import com.google.api.services.calendar.Calendar; +import com.google.api.services.calendar.model.Event; +import com.google.api.services.calendar.model.EventDateTime; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +@Slf4j +@Service +public class GoogleCalendarService { + + private static final String TIMEZONE = "Asia/Seoul"; + + private final Calendar googleCalendar; + + @Autowired + public GoogleCalendarService(@Autowired(required = false) Calendar googleCalendar) { + this.googleCalendar = googleCalendar; + } + + public boolean isEnabled() { + return googleCalendar != null; + } + + public String createEvent(String calendarId, String title, LocalDate startDate, LocalDate endDate) { + return createEvent(calendarId, title, startDate, endDate, null, null); + } + + public String createEvent(String calendarId, String title, LocalDate startDate, LocalDate endDate, + LocalTime startTime, LocalTime endTime) { + if (!isEnabled()) return null; + try { + Event event = buildEvent(title, startDate, endDate, startTime, endTime); + Event created = googleCalendar.events().insert(calendarId, event).execute(); + log.info("Google Calendar 이벤트 생성 완료: calendarId={}, eventId={}", calendarId, created.getId()); + return created.getId(); + } catch (Exception e) { + log.error("Google Calendar 이벤트 생성 실패: calendarId={}, title={}", calendarId, title, e); + return null; + } + } + + public String updateEvent(String calendarId, String eventId, String title, LocalDate startDate, LocalDate endDate) { + return updateEvent(calendarId, eventId, title, startDate, endDate, null, null); + } + + public String updateEvent(String calendarId, String eventId, String title, LocalDate startDate, LocalDate endDate, + LocalTime startTime, LocalTime endTime) { + if (!isEnabled()) return null; + try { + Event event = buildEvent(title, startDate, endDate, startTime, endTime); + Event updated = googleCalendar.events().update(calendarId, eventId, event).execute(); + log.info("Google Calendar 이벤트 수정 완료: calendarId={}, eventId={}", calendarId, updated.getId()); + return updated.getId(); + } catch (Exception e) { + log.error("Google Calendar 이벤트 수정 실패: calendarId={}, eventId={}", calendarId, eventId, e); + return null; + } + } + + public void deleteEvent(String calendarId, String eventId) { + if (!isEnabled()) return; + try { + googleCalendar.events().delete(calendarId, eventId).execute(); + log.info("Google Calendar 이벤트 삭제 완료: calendarId={}, eventId={}", calendarId, eventId); + } catch (Exception e) { + log.error("Google Calendar 이벤트 삭제 실패: calendarId={}, eventId={}", calendarId, eventId, e); + } + } + + // AIDEV-NOTE: startTime이 있으면 시간 지정 이벤트, 없으면 종일 이벤트로 생성 + private Event buildEvent(String title, LocalDate startDate, LocalDate endDate, + LocalTime startTime, LocalTime endTime) { + Event event = new Event().setSummary(title); + + if (startTime != null) { + ZonedDateTime startZdt = startDate.atTime(startTime).atZone(ZoneId.of(TIMEZONE)); + event.setStart(new EventDateTime() + .setDateTime(new DateTime(startZdt.toInstant().toEpochMilli())) + .setTimeZone(TIMEZONE)); + + LocalTime effectiveEndTime = endTime != null ? endTime : startTime.plusHours(1); + LocalDate effectiveEndDate = endDate != null ? endDate : startDate; + ZonedDateTime endZdt = effectiveEndDate.atTime(effectiveEndTime).atZone(ZoneId.of(TIMEZONE)); + event.setEnd(new EventDateTime() + .setDateTime(new DateTime(endZdt.toInstant().toEpochMilli())) + .setTimeZone(TIMEZONE)); + } else { + // AIDEV-NOTE: Google Calendar all-day 이벤트의 endDate는 exclusive이므로 +1일 처리 + event.setStart(new EventDateTime() + .setDate(new DateTime(startDate.toString()))); + + LocalDate effectiveEnd = (endDate != null ? endDate : startDate).plusDays(1); + event.setEnd(new EventDateTime() + .setDate(new DateTime(effectiveEnd.toString()))); + } + + return event; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 52b981cb..9f9388f3 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -65,4 +65,10 @@ service: telegram: bot-token: ${TELEGRAM_BOT_TOKEN} - chat-id: ${TELEGRAM_CHAT_ID} \ No newline at end of file + chat-id: ${TELEGRAM_CHAT_ID} + +google: + calendar: + service-account-key: ${GOOGLE_SERVICE_ACCOUNT_KEY:} + seminar-calendar-id: ${GOOGLE_SEMINAR_CALENDAR_ID:} + leave-calendar-id: ${GOOGLE_LEAVE_CALENDAR_ID:} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index b1d1d305..ce736de5 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -63,4 +63,10 @@ service: telegram: bot-token: ${TELEGRAM_BOT_TOKEN} - chat-id: ${TELEGRAM_CHAT_ID} \ No newline at end of file + chat-id: ${TELEGRAM_CHAT_ID} + +google: + calendar: + service-account-key: ${GOOGLE_SERVICE_ACCOUNT_KEY:} + seminar-calendar-id: ${GOOGLE_SEMINAR_CALENDAR_ID:} + leave-calendar-id: ${GOOGLE_LEAVE_CALENDAR_ID:} \ No newline at end of file