Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.Optional;

@Component
@RequiredArgsConstructor
public class MemberStorage {
Expand All @@ -27,6 +29,9 @@ public Mentor findMentorByMemberId(Long memberId) {
return mentorRepository.findByMemberIdWithMember(memberId)
.orElseThrow(() -> new ServiceException(MemberErrorCode.NOT_FOUND_MENTOR));
}
public Optional<Mentor> findMentorByMemberOptional(Member member) {
return mentorRepository.findByMemberIdWithMember(member.getId());
}

public Mentee findMenteeByMember(Member member) {
return menteeRepository.findByMemberIdWithMember(member.getId())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import com.back.domain.mentoring.mentoring.entity.Mentoring;
import com.back.domain.mentoring.mentoring.error.MentoringErrorCode;
import com.back.domain.mentoring.mentoring.repository.MentoringRepository;
import com.back.domain.mentoring.reservation.entity.Reservation;
import com.back.domain.mentoring.reservation.error.ReservationErrorCode;
import com.back.domain.mentoring.reservation.repository.ReservationRepository;
import com.back.domain.mentoring.slot.entity.MentorSlot;
import com.back.domain.mentoring.slot.error.MentorSlotErrorCode;
Expand Down Expand Up @@ -52,6 +54,11 @@ public MentorSlot findMentorSlot(Long slotId) {
.orElseThrow(() -> new ServiceException(MentorSlotErrorCode.NOT_FOUND_MENTOR_SLOT));
}

public Reservation findReservation(Long reservationId) {
return reservationRepository.findById(reservationId)
.orElseThrow(() -> new ServiceException(ReservationErrorCode.NOT_FOUND_RESERVATION));
}


// ==== exists 메서드 =====

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,21 @@ public enum ReservationStatus {
APPROVED, // 승인됨
REJECTED, // 거절됨
CANCELED, // 취소됨
COMPLETED // 완료됨
COMPLETED; // 완료됨

public boolean canApprove() {
return this == PENDING;
}

public boolean canReject() {
return this == PENDING;
}

public boolean canCancel() {
return this == PENDING || this == APPROVED;
}

public boolean canComplete() {
return this == APPROVED;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.back.domain.mentoring.reservation.controller;

import com.back.domain.member.member.entity.Member;
import com.back.domain.member.member.service.MemberStorage;
import com.back.domain.member.mentee.entity.Mentee;
import com.back.domain.member.mentor.entity.Mentor;
import com.back.domain.mentoring.reservation.dto.request.ReservationRequest;
import com.back.domain.mentoring.reservation.dto.response.ReservationResponse;
import com.back.domain.mentoring.reservation.service.ReservationService;
Expand All @@ -12,10 +14,9 @@
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

import java.util.Optional;

@RestController
@RequestMapping("/reservations")
Expand Down Expand Up @@ -43,4 +44,61 @@ public RsData<ReservationResponse> createReservation(
resDto
);
}

@PatchMapping("/{reservationId}/approve")
@PreAuthorize("hasRole('MENTOR')")
@Operation(summary = "예약 수락", description = "멘토가 멘티의 예약 신청을 수락합니다. 로그인한 멘토만 예약 수락할 수 있습니다.")
public RsData<ReservationResponse> approveReservation(
@PathVariable Long reservationId
) {
Mentor mentor = memberStorage.findMentorByMember(rq.getActor());

ReservationResponse resDto = reservationService.approveReservation(mentor, reservationId);

return new RsData<>(
"200",
"예약이 수락되었습니다.",
resDto
);
}

@PatchMapping("/{reservationId}/reject")
@PreAuthorize("hasRole('MENTOR')")
@Operation(summary = "예약 거절", description = "멘토가 멘티의 예약 신청을 거절합니다. 로그인한 멘토만 예약 거절할 수 있습니다.")
public RsData<ReservationResponse> rejectReservation(
@PathVariable Long reservationId
) {
Mentor mentor = memberStorage.findMentorByMember(rq.getActor());

ReservationResponse resDto = reservationService.rejectReservation(mentor, reservationId);

return new RsData<>(
"200",
"예약이 거절되었습니다.",
resDto
);
}

@PatchMapping("/{reservationId}/cancel")
@Operation(summary = "예약 취소", description = "멘토 또는 멘티가 예약을 취소합니다. 로그인 후 예약 취소할 수 있습니다.")
public RsData<ReservationResponse> cancelReservation(
@PathVariable Long reservationId
) {
Member member = rq.getActor();
ReservationResponse resDto;

Optional<Mentor> mentor = memberStorage.findMentorByMemberOptional(member);
if (mentor.isPresent()) {
resDto = reservationService.cancelReservation(mentor.get(), reservationId);
} else {
Mentee mentee = memberStorage.findMenteeByMember(member);
resDto = reservationService.cancelReservation(mentee, reservationId);
}

return new RsData<>(
"200",
"예약이 취소되었습니다.",
resDto
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import com.back.domain.member.mentor.entity.Mentor;
import com.back.domain.mentoring.mentoring.entity.Mentoring;
import com.back.domain.mentoring.reservation.constant.ReservationStatus;
import com.back.domain.mentoring.reservation.error.ReservationErrorCode;
import com.back.domain.mentoring.slot.entity.MentorSlot;
import com.back.global.exception.ServiceException;
import com.back.global.jpa.BaseEntity;
import jakarta.persistence.*;
import lombok.Builder;
Expand Down Expand Up @@ -38,6 +40,9 @@ public class Reservation extends BaseEntity {
@Column(nullable = false)
private ReservationStatus status;

@Version
private Long version;

@Builder
public Reservation(Mentoring mentoring, Mentee mentee, MentorSlot mentorSlot, String preQuestion) {
this.mentoring = mentoring;
Expand All @@ -48,18 +53,104 @@ public Reservation(Mentoring mentoring, Mentee mentee, MentorSlot mentorSlot, St
this.status = ReservationStatus.PENDING;
}

public void updateStatus(ReservationStatus status) {
private void updateStatus(ReservationStatus status) {
this.status = status;

// 양방향 동기화
if (status.equals(ReservationStatus.CANCELED) || status.equals(ReservationStatus.REJECTED)) {
if (status == ReservationStatus.CANCELED || status == ReservationStatus.REJECTED) {
mentorSlot.removeReservation();
} else {
mentorSlot.updateStatus();
}
}

public boolean isMentor(Mentor mentor) {
return this.mentor.equals(mentor);
}

public boolean isMentee(Mentee mentee) {
return this.mentee.equals(mentee);
}

public void approve(Mentor mentor) {
ensureMentor(mentor);
ensureCanApprove();
ensureNotPast();
updateStatus(ReservationStatus.APPROVED);
}

public void reject(Mentor mentor) {
ensureMentor(mentor);
ensureCanReject();
ensureNotPast();
updateStatus(ReservationStatus.REJECTED);
}

public void cancel(Mentor mentor) {
ensureMentor(mentor);
ensureCanCancel();
ensureNotPast();
updateStatus(ReservationStatus.CANCELED);
}

public void cancel(Mentee mentee) {
ensureMentee(mentee);
ensureCanCancel();
ensureNotPast();
updateStatus(ReservationStatus.CANCELED);
}

public void complete() {
ensureCanComplete();
updateStatus(ReservationStatus.COMPLETED);
}


// ===== 헬퍼 메서드 =====

private void ensureMentor(Mentor mentor) {
if (!isMentor(mentor)) {
throw new ServiceException(ReservationErrorCode.FORBIDDEN_NOT_MENTOR);
}
}

private void ensureMentee(Mentee mentee) {
if (!isMentee(mentee)) {
throw new ServiceException(ReservationErrorCode.FORBIDDEN_NOT_MENTEE);
}
}

private void ensureCanApprove() {
if(!this.status.canApprove()) {
throw new ServiceException(ReservationErrorCode.CANNOT_APPROVE);
}
}

private void ensureCanReject() {
if(!this.status.canReject()) {
throw new ServiceException(ReservationErrorCode.CANNOT_REJECT);
}
}

private void ensureCanCancel() {
if(!this.status.canCancel()) {
throw new ServiceException(ReservationErrorCode.CANNOT_CANCEL);
}
}

private void ensureCanComplete() {
if(!this.status.canComplete()) {
throw new ServiceException(ReservationErrorCode.CANNOT_COMPLETE);
}
// 시작 이후 완료 가능 (조기 종료 허용)
if (!mentorSlot.isPast()) {
throw new ServiceException(ReservationErrorCode.MENTORING_NOT_STARTED);
}
}

private void ensureNotPast() {
if (mentorSlot.isPast()) {
throw new ServiceException(ReservationErrorCode.INVALID_MENTOR_SLOT);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,28 @@
@Getter
@RequiredArgsConstructor
public enum ReservationErrorCode implements ErrorCode {

// 400
CANNOT_APPROVE("400-1", "예약 요청 상태가 아닙니다. 수락이 불가능합니다."),
CANNOT_REJECT("400-2", "예약 요청 상태가 아닙니다. 거절이 불가능합니다."),
CANNOT_CANCEL("400-3", "예약 요청 상태 또는 예약 승인 상태가 아닙니다. 취소가 불가능합니다."),
CANNOT_COMPLETE("400-4", "예약 승인 상태가 아닙니다. 완료가 불가능합니다."),
INVALID_MENTOR_SLOT("400-5", "이미 시간이 지난 슬롯입니다. 예약 상태 변경이 불가능합니다."),
MENTORING_NOT_STARTED("400-6", "멘토링이 시작되지 않았습니다. 완료가 불가능합니다."),


// 403
FORBIDDEN_NOT_MENTOR("403-1", "해당 예약에 대한 멘토 권한이 없습니다."),
FORBIDDEN_NOT_MENTEE("403-2", "해당 예약에 대한 멘티 권한이 없습니다."),

// 404
NOT_FOUND_RESERVATION("404-1", "예약이 존재하지 않습니다."),

// 409
NOT_AVAILABLE_SLOT("409-1", "이미 예약이 완료된 시간대입니다."),
ALREADY_RESERVED_SLOT("409-2", "이미 예약한 시간대입니다. 예약 목록을 확인해 주세요."),
CONCURRENT_RESERVATION_CONFLICT("409-3", "다른 사용자가 먼저 예약했습니다. 새로고침 후 다시 시도해 주세요.");
CONCURRENT_RESERVATION_CONFLICT("409-3", "다른 사용자가 먼저 예약했습니다. 새로고침 후 다시 시도해 주세요."),
CONCURRENT_APPROVAL_CONFLICT("409-4", "이미 수락한 예약입니다.");

private final String code;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.back.domain.mentoring.reservation.service;

import com.back.domain.member.mentee.entity.Mentee;
import com.back.domain.member.mentor.entity.Mentor;
import com.back.domain.mentoring.mentoring.entity.Mentoring;
import com.back.domain.mentoring.mentoring.service.MentoringStorage;
import com.back.domain.mentoring.reservation.constant.ReservationStatus;
Expand Down Expand Up @@ -44,6 +45,7 @@ public ReservationResponse createReservation(Mentee mentee, ReservationRequest r
.build();

mentorSlot.setReservation(reservation);
// flush 필요...?

reservationRepository.save(reservation);

Expand All @@ -53,6 +55,42 @@ public ReservationResponse createReservation(Mentee mentee, ReservationRequest r
}
}

@Transactional
public ReservationResponse approveReservation(Mentor mentor, Long reservationId) {
try {
Reservation reservation = mentoringStorage.findReservation(reservationId);

reservation.approve(mentor);

// 세션

return ReservationResponse.from(reservation);
} catch (OptimisticLockException e) {
throw new ServiceException(ReservationErrorCode.CONCURRENT_APPROVAL_CONFLICT);
}
}

@Transactional
public ReservationResponse rejectReservation(Mentor mentor, Long reservationId) {
Reservation reservation = mentoringStorage.findReservation(reservationId);
reservation.reject(mentor);
return ReservationResponse.from(reservation);
}

@Transactional
public ReservationResponse cancelReservation(Mentor mentor, Long reservationId) {
Reservation reservation = mentoringStorage.findReservation(reservationId);
reservation.cancel(mentor);
return ReservationResponse.from(reservation);
}

@Transactional
public ReservationResponse cancelReservation(Mentee mentee, Long reservationId) {
Reservation reservation = mentoringStorage.findReservation(reservationId);
reservation.cancel(mentee);
return ReservationResponse.from(reservation);
}


// ===== 검증 메서드 =====

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,8 @@ public boolean isAvailable() {
public boolean isOwnerBy(Mentor mentor) {
return this.mentor.equals(mentor);
}

public boolean isPast() {
return startDateTime.isBefore(LocalDateTime.now());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,9 @@ void deleteMentoringSuccess() throws Exception {
@DisplayName("멘토링 삭제 성공 - 멘토 슬롯이 있는 경우")
void deleteMentoringSuccessExistsMentorSlot() throws Exception {
Mentoring mentoring = mentoringFixture.createMentoring(mentor);
LocalDateTime baseDateTime = LocalDateTime.of(2025, 10, 1, 10, 0);
mentoringFixture.createMentorSlots(mentor, baseDateTime, 3, 2);

LocalDateTime baseDateTime = LocalDateTime.now().plusMonths(3);
mentoringFixture.createMentorSlots(mentor, baseDateTime, 3, 2, 30L);

long preMentoringCnt = mentoringRepository.count();
long preSlotCnt = mentorSlotRepository.count();
Expand Down
Loading