본문 바로가기
스프링과 JPA 기반 웹 어플리케이션 개발/5부 모임

67. 모임 참가 신청 및 취소

by Backchus 2020. 5. 6.

모임 참가 신청 및 취소 시 스터디 조회

  • 이때 조회하는 스터디는 관리자 권한 없이 읽어올 수 있어야 하며 (스터디 관리자가 아니어도 참가 신청이 가능해야 하니까) 데이터를 필요한 만큼만 가져오도록 주의할 것.

모임 참가 신청

  • 선착순 모임이고 현재까지 수락한 참가 신청 개수와 총 모집 인원수를 확인한다면, 가능하다면 해당 참가 신청을 확정 상태로 저장합니다.

모임 참가 신청 취소

  • 선착순 모임이라면, 대기 중인 모임 참가 신청 중에 가장 빨리 신청한 것을 확정 상태로 변경합니다.

모임 수정 로직 보완

  • 선착순 모임 수정시 모집 인원이 늘었고 대기 중인 참가 신청이 있다면 가능한 만큼 대기 중인 신청을 확정 상태로 변경합니다.

테스트 코드 작성 필수

 

모임 관리자가 참가 신청을 관리하기위해 Event 엔티티에 canAccept, canReject 메소드를 추가(선착순이아닌 관리자가 확인해야하는 경우 (CONFIRMATIVE타입인 경우))

package me.weekbelt.studyolle.domain;

@NamedEntityGraph(
        name = "Event.withEnrollments",
        attributeNodes = @NamedAttributeNode("enrollments")
)
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(of = "id")
@Entity
public class Event {

    // ..........

    public boolean canAccept(Enrollment enrollment) {
        return this.eventType == EventType.CONFIRMATIVE
                && this.enrollments.contains(enrollment)
                && !enrollment.isAttended()
                && !enrollment.isAccepted();
    }

    public boolean canReject(Enrollment enrollment) {
        return this.eventType == EventType.CONFIRMATIVE
                && !enrollments.contains(enrollment)
                && !enrollment.isAttended()
                && enrollment.isAccepted();
    }
}

 

모임 참가 신청/취소 요청을 처리하는 핸들러 생성

package me.weekbelt.studyolle.event;

@Controller
@RequestMapping("/study/{path}")
@RequiredArgsConstructor
public class EventController {

    // ..............

    @PostMapping("/events/{id}/enroll")
    public String newEnrollment(@CurrentAccount Account account, @PathVariable String path,
                                @PathVariable Long id) {
        Study study = studyService.getStudyToEnroll(path);
        eventService.newEnrollment(eventService.findEventById(id), account);
        return "redirect:/study/" + study.getEncodedPath() + "/events/" + id;
    }

    @PostMapping("/events/{id}/disenroll")
    public String cancelEnrollment(@CurrentAccount Account account, @PathVariable String path,
                                @PathVariable Long id) {
        Study study = studyService.getStudyToEnroll(path);
        eventService.cancelEnrollment(eventService.findEventById(id), account);
        return "redirect:/study/" + study.getEncodedPath() + "/events/" + id;
    }
}

 

참가 신청을 신청/취소할때 꼭 관리자일 필요는 없으므로 getSutdyToEnroll메소드로 관리자 권한상관없이 Study를 가져오는 메소드를 작성

package me.weekbelt.studyolle.study;


@RequiredArgsConstructor
@Transactional
@Service
public class StudyService {

    // ..........

    public Study getStudyToEnroll(String path) {
        return studyRepository.findStudyOnlyByPath(path)
                .orElseThrow(() -> new IllegalArgumentException(path + "에 해당하는 스터디가 없습니다."));
    }
}

 

다른 연관관계 없이 Study를 호출하는 findStudyOnlyByPath를 추가

package me.weekbelt.studyolle.study;

@Transactional(readOnly = true)
public interface StudyRepository extends JpaRepository<Study, Long> {
    
    // .......
    
    Optional<Study> findStudyOnlyByPath(String path);
}

 

모임 참가 신청을 처리하는 메소드작성

package me.weekbelt.studyolle.event;

@Service
@Transactional
@RequiredArgsConstructor
public class EventService {

    // .........
    
    public void newEnrollment(Event event, Account account) {
        if (!enrollmentRepository.existsByEventAndAccount(event, account)) {
            Enrollment enrollment = new Enrollment();
            enrollment.setEnrolledAt(LocalDateTime.now());
            enrollment.setAccepted(event.isAbleToAcceptWaitingEnrollment());
            enrollment.setAccount(account);
            event.addEnrollment(enrollment);
            enrollmentRepository.save(enrollment);
        }
    }


}

 

모임 참가자들의 대기중에 확정 가능한지 판별하기위해 Event엔티티에 isAbleToAcceptWaitingEnrollment메소드 추가

package me.weekbelt.studyolle.domain;

@NamedEntityGraph(
        name = "Event.withEnrollments",
        attributeNodes = @NamedAttributeNode("enrollments")
)
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(of = "id")
@Entity
public class Event {

    // ........
    
    public boolean isAbleToAcceptWaitingEnrollment() {
        return this.eventType == EventType.FCFS
                && this.limitOfEnrollments > this.getNumberOfAcceptedEnrollments();
    }
}

 

모임에 신청을 추가하는 메소드를 Event 엔티티에 추가

package me.weekbelt.studyolle.domain;

@NamedEntityGraph(
        name = "Event.withEnrollments",
        attributeNodes = @NamedAttributeNode("enrollments")
)
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(of = "id")
@Entity
public class Event {

    // .......
    
    public void addEnrollment(Enrollment enrollment) {
        this.enrollments.add(enrollment);
        enrollment.setEvent(this);
    }
}

 

모임 참가 취소 신청을 처리하는 메소드 작성

package me.weekbelt.studyolle.event;

@Service
@Transactional
@RequiredArgsConstructor
public class EventService {

    // ......
    
    public void cancelEnrollment(Event event, Account account) {
        Enrollment enrollment = enrollmentRepository.findByEventAndAccount(event, account);
        event.removeEnrollment(enrollment);
        enrollmentRepository.delete(enrollment);
        event.acceptNextWaitingEnrollment();
    }
}

 

모임과 모임에 참가한 계정과 관련된 Enrollment를 찾기 위해 EnrollmentRepository에 findByEventAndAccount 메소드 추가

package me.weekbelt.studyolle.event;

public interface EnrollmentRepository extends JpaRepository<Enrollment, Long> {
    
    // .......

    Enrollment findByEventAndAccount(Event event, Account account);
}

 

모임의 참가 신청을 취소하기 위한 로직을 Event 엔티티에 작성

package me.weekbelt.studyolle.domain;

@NamedEntityGraph(
        name = "Event.withEnrollments",
        attributeNodes = @NamedAttributeNode("enrollments")
)
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(of = "id")
@Entity
public class Event {

    // .......
    
    public void removeEnrollment(Enrollment enrollment) {
        this.enrollments.remove(enrollment);
        enrollment.setEvent(null);
    }
}

 

참가 확정되지 않은 첫번째 대기중인 참가자를 불러와서 참가확정 처리를 하는 메소드를 Event 엔티티에 작성

package me.weekbelt.studyolle.domain;

@NamedEntityGraph(
        name = "Event.withEnrollments",
        attributeNodes = @NamedAttributeNode("enrollments")
)
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(of = "id")
@Entity
public class Event {

    // .......

    public void acceptNextWaitingEnrollment() {
        if(this.isAbleToAcceptWaitingEnrollment()) {
            Enrollment enrollmentToAccept = this.getTheFirstWaitingEnrollment();
            if (enrollmentToAccept != null){
                enrollmentToAccept.setAccepted(true);
            }
        }
    }

    private Enrollment getTheFirstWaitingEnrollment() {
        for (Enrollment enrollment : this.enrollments) {
            if (!enrollment.isAccepted()) {
                return enrollment;
            }
        }
        return null;
    }
}

 

그리고 기존에 event를 수정할때 참가인원을 늘릴 경우 추가된 인원만큼 참가 확정시키기 위한 로직을 Event 엔티티에 추가한다.

package me.weekbelt.studyolle.event;

@Service
@Transactional
@RequiredArgsConstructor
public class EventService {

    // ..........

    public void updateEvent(Event event, EventForm eventForm) {
        modelMapper.map(eventForm, event);
        event.acceptWaitingList();         // 추가
    }

    // ........
}
package me.weekbelt.studyolle.domain;

@NamedEntityGraph(
        name = "Event.withEnrollments",
        attributeNodes = @NamedAttributeNode("enrollments")
)
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(of = "id")
@Entity
public class Event {

    // ........
    
    public void acceptWaitingList() {
        if(this.isAbleToAcceptWaitingEnrollment()) {
            var waitingList = getWaitingList();  // 참가 확정 대기중의 리스트를 가져옴
            int numberToAccept = (int) Math.min(this.limitOfEnrollments - this.getNumberOfAcceptedEnrollments(), waitingList.size());
            waitingList.subList(0, numberToAccept).forEach(e -> e.setAccepted(true));
        }
    }

    private List<Enrollment> getWaitingList() {
        return this.enrollments.stream().filter(enrollment -> !enrollment.isAccepted())
                .collect(Collectors.toList());
    }
}

 

테스트 코드 작성

package me.weekbelt.studyolle.event;

class EventControllerTest extends StudyControllerTest {

    @Autowired
    EventService eventService;
    @Autowired
    EnrollmentRepository enrollmentRepository;

    @DisplayName("선착순 모임에 참가 신청 - 자동 수락")
    @WithAccount("joohyuk")
    @Test
    public void newEnrollment_to_FCFS_event_accepted() throws Exception {
        Account weekbelt = createAccount("weekbelt");
        Study study = createStudy("test-study", weekbelt);
        Event event = createEvent("test-event", EventType.FCFS, 2, study, weekbelt);

        mockMvc.perform(post("/study/" + study.getPath() + "/events/" + event.getId() + "/enroll")
                .with(csrf()))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/study/" + study.getPath() + "/events/" + event.getId()));


        Account joohyuk = accountRepository.findByNickname("joohyuk");
        isAccepted(joohyuk, event);
    }

    @DisplayName("선착순 모임에 참가 신청 - 대기중 (이미 인원이 꽉차서)")
    @WithAccount("joohyuk")
    @Test
    public void newEnrollment_to_FCFS_event_not_accepted() throws Exception {
        Account weekbelt = createAccount("weekbelt");
        Study study = createStudy("test-study", weekbelt);
        Event event = createEvent("test-event", EventType.FCFS, 2, study, weekbelt);

        Account may = createAccount("may");
        Account june = createAccount("june");
        eventService.newEnrollment(event, may);
        eventService.newEnrollment(event, june);

        mockMvc.perform(post("/study/" + study.getPath() + "/events/" + event.getId() + "/enroll")
                .with(csrf()))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/study/" + study.getPath() + "/events/" + event.getId()));


        Account joohyuk = accountRepository.findByNickname("joohyuk");
        isNotAccepted(joohyuk, event);
    }

    @DisplayName("선착순 모임에 참가 신청 - 취소")
    @WithAccount("joohyuk")
    @Test
    public void cancelEnrollment_to_FCFS_event_not_accepted() throws Exception {
        Account joohyuk = accountRepository.findByNickname("joohyuk");
        Account weekbelt = createAccount("weekbelt");
        Account may = createAccount("may");
        Study study = createStudy("test-study", weekbelt);
        Event event = createEvent("test-event", EventType.FCFS, 2, study, weekbelt);

        eventService.newEnrollment(event, may);
        eventService.newEnrollment(event, joohyuk);
        eventService.newEnrollment(event, weekbelt);

        isAccepted(may, event);
        isAccepted(joohyuk, event);
        isNotAccepted(weekbelt, event);

        mockMvc.perform(post("/study/" + study.getPath() + "/events/" + event.getId() + "/disenroll")
                .with(csrf()))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/study/" + study.getPath() + "/events/" + event.getId()));

        isAccepted(may, event);
        isAccepted(weekbelt, event);
        assertThat(enrollmentRepository.findByEventAndAccount(event, joohyuk)).isNull();
    }

    @DisplayName("관리자 확인 모임에 참가 신청 - 대기중")
    @WithAccount("joohyuk")
    @Test
    public void newEnrollment_to_CONFIRMATIVE_event_not_accepted() throws Exception {
        Account weekbelt = createAccount("weekbelt");
        Study study = createStudy("test-study", weekbelt);
        Event event = createEvent("test-event", EventType.CONFIRMATIVE, 2, study, weekbelt);

        mockMvc.perform(post("/study/" + study.getPath() + "/events/" + event.getId() + "/enroll")
                .with(csrf()))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/study/" + study.getPath() + "/events/" + event.getId()));

        Account joohyuk = accountRepository.findByNickname("joohyuk");
        isNotAccepted(joohyuk, event);
    }

    private void isAccepted(Account joohyuk, Event event) {
        assertThat(enrollmentRepository.findByEventAndAccount(event, joohyuk).isAccepted()).isTrue();
    }

    private void isNotAccepted(Account joohyuk, Event event) {
        assertThat(enrollmentRepository.findByEventAndAccount(event, joohyuk).isAccepted()).isFalse();
    }

    public Account createAccount(String nickname) {
        Account account = new Account();
        account.setNickname(nickname);
        account.setEmail(nickname + "@email.com");
        accountRepository.save(account);
        return account;
    }

    public Study createStudy(String path, Account manager) {
        Study study = new Study();
        study.setPath(path);
        studyService.createNewStudy(study, manager);
        return study;
    }

    private Event createEvent(String eventTitle, EventType eventType, int limit, Study study, Account account) {
        Event event = new Event();
        event.setEventType(eventType);
        event.setLimitOfEnrollments(limit);
        event.setTitle(eventTitle);
        event.setCreatedDateTime(LocalDateTime.now());
        event.setEndEnrollmentDateTime(LocalDateTime.now().plusDays(1));
        event.setStartDateTime(LocalDateTime.now().plusDays(1).plusHours(5));
        event.setEndDateTime(LocalDateTime.now().plusDays(1).plusHours(7));
        return eventService.createEvent(event, study, account);
    }
}

 

참고: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-JPA-%EC%9B%B9%EC%95%B1#

 

스프링과 JPA 기반 웹 애플리케이션 개발 - 인프런

이 강좌에서 여러분은 실제로 운영 중인 서비스를 스프링, JPA 그리고 타임리프를 비롯한 여러 자바 기반의 여러 오픈 소스 기술을 사용하여 웹 애플리케이션을 개발하는 과정을 학습할 수 있습니다. 이 강좌를 충분히 학습한다면 여러분 만의 웹 서비스를 만들거나 취직에 도움이 될만한 포트폴리오를 만들 수 있을 겁니다. 중급이상 웹 개발 프레임워크 및 라이브러리 Java Spring Spring Boot Spring Data JPA Thymeleaf 온라인 강의

www.inflearn.com

 

'스프링과 JPA 기반 웹 어플리케이션 개발 > 5부 모임' 카테고리의 다른 글

68. 모임 참가 신청 수락 및 출석 체크  (0) 2020.05.06
66. 모임 취소  (0) 2020.05.04
65. 모임 수정  (0) 2020.05.04
64. 모임 목록 조회  (0) 2020.05.04
63. 모임 조회  (0) 2020.05.04