Skip to content
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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<Leave> leaves =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public ResponseEntity<AwardResponse> 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);
}

Expand All @@ -40,7 +40,8 @@ public ResponseEntity<AwardResponse> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -51,6 +56,7 @@ public AwardResponse createAward(CreateAwardRequest dto) {
.presentationTitle(dto.presentationTitle())
.project(project)
.task(task)
.createdBy(creator)
.build();
awardRepository.save(newAward);

Expand All @@ -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);
}
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
})
Expand All @@ -33,9 +35,11 @@ ResponseEntity<JournalResponse> 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)))
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ public ResponseEntity<JournalResponse> 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);
}

Expand All @@ -40,7 +41,8 @@ public ResponseEntity<JournalResponse> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public ResponseEntity<PaperResponse> 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);
}

Expand All @@ -40,7 +40,8 @@ public ResponseEntity<PaperResponse> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Loading
Loading