diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java index b2368aef..211c9777 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java @@ -4,11 +4,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest; +import kr.ac.kookmin.stream.api.admin.event.event.request.EventPublishRequest; import kr.ac.kookmin.stream.api.admin.event.event.request.EventSearchParams; import kr.ac.kookmin.stream.api.admin.event.event.request.EventUpdateRequest; import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventDetailResponse; import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventListItemResponse; import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse; +import kr.ac.kookmin.stream.api.admin.event.event.response.EventPublishResponse; import kr.ac.kookmin.stream.api.admin.event.event.response.EventUpdateResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.PageParams; @@ -24,7 +26,7 @@ *

* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. */ -@Tag(name = "행사 관리", description = "운영진 행사 등록·조회·수정") +@Tag(name = "행사 관리", description = "운영진 행사 등록·조회·수정·공개 전환·삭제") public interface AdminEventApi { /** 행사 등록. 작성자는 토큰의 관리자로 정해진다. */ @@ -71,4 +73,19 @@ ApiResponse> getEvents( ) @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"}) ApiResponse updateEvent(AdminApiUser apiUser, Long eventId, EventUpdateRequest request); + + /** 공개 상태 변경. 공개한 행사만 학생 앱 목록·상세에 보인다. */ + @Operation(summary = "행사 공개 상태 변경", + description = "행사를 학생 앱에 공개하거나 비공개로 돌린다. 이미 그 상태여도 성공으로 응답한다. " + + "비공개로 돌려도 기존 신청 내역은 유지된다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"}) + ApiResponse changePublished(AdminApiUser apiUser, Long eventId, EventPublishRequest request); + + /** 행사 삭제. 소프트 삭제라 데이터는 남지만 어디에도 보이지 않는다. */ + @Operation(summary = "행사 삭제", + description = "행사를 삭제한다. 신청자가 있어도 지울 수 있으며, 행사와 그 신청 내역은 운영진 화면과 " + + "학생 앱(내 신청 내역 포함)에서 보이지 않게 된다. 데이터는 지우지 않고 복구 기능은 없다.") + @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"}) + ApiResponse deleteEvent(AdminApiUser apiUser, Long eventId); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationApi.java new file mode 100644 index 00000000..f491c94a --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationApi.java @@ -0,0 +1,44 @@ +package kr.ac.kookmin.stream.api.admin.event.event; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.event.request.EventApplicationSearchParams; +import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationDetailResponse; +import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationListResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.dto.PageParams; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; +import org.springdoc.core.annotations.ParameterObject; + +/** + * 운영진 행사 신청 현황 API의 문서 명세. 구현은 {@link AdminEventApplicationController}가 맡는다. + *

+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. + */ +@Tag(name = "행사 신청 현황", description = "운영진 행사 신청자 목록·상세 조회") +public interface AdminEventApplicationApi { + + /** 신청 현황. 한 줄에 신청자 정보와 질문별 답변을 함께 내려준다. */ + @Operation(summary = "행사 신청 현황 조회", + description = "행사의 신청을 신청 순으로 페이지 단위 조회한다. 취소된 신청도 함께 내려가며, keyword로 이름·학번을, " + + "status로 신청 상태를 거른다. 답변은 질문 순서대로 담기고, 선택형은 고른 선택지 문구를 쉼표로 잇는다. " + + "탈퇴한 회원의 신청은 이름·학번이 비어 있다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND", "EVENT_INVALID_APPLICATION_STATUS"}) + ApiResponse getApplications( + AdminApiUser apiUser, + Long eventId, + @ParameterObject EventApplicationSearchParams params, + @ParameterObject PageParams pageParams + ); + + /** 신청자 상세. 표에서 잘린 답변을 질문 전체와 함께 확인한다. */ + @Operation(summary = "행사 신청자 상세 조회", + description = "신청 한 건의 신청자 정보와, 행사 질문 전체에 이 신청의 답변을 붙여 조회한다. 답하지 않은 질문도 " + + "빈 답변으로 함께 내려간다. 다른 행사의 신청이면 찾을 수 없다.") + @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND", "APPLICATION_NOT_FOUND"}) + ApiResponse getApplication(AdminApiUser apiUser, Long eventId, Long applicationId); +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationController.java new file mode 100644 index 00000000..23e9b8f1 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationController.java @@ -0,0 +1,47 @@ +package kr.ac.kookmin.stream.api.admin.event.event; + +import jakarta.validation.Valid; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.event.request.EventApplicationSearchParams; +import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationDetailResponse; +import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationListResponse; +import kr.ac.kookmin.stream.api.admin.event.event.usecase.AdminEventApplicationQueryUseCase; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.dto.PageParams; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/events/{eventId}/applications") +@RequiredArgsConstructor +public class AdminEventApplicationController implements AdminEventApplicationApi { + + private final AdminEventApplicationQueryUseCase adminEventApplicationQueryUseCase; + + @Override + @GetMapping + public ApiResponse getApplications( + AdminApiUser apiUser, + @PathVariable Long eventId, + @Valid @ModelAttribute EventApplicationSearchParams params, + @Valid @ModelAttribute PageParams pageParams + ) { + return ApiResponse.success(AdminEventApplicationListResponse.from(adminEventApplicationQueryUseCase.getApplicants( + eventId, params.toKeyword(), params.toStatus(), pageParams.toOffset()))); + } + + @Override + @GetMapping("/{applicationId}") + public ApiResponse getApplication( + AdminApiUser apiUser, + @PathVariable Long eventId, + @PathVariable Long applicationId + ) { + return ApiResponse.success(AdminEventApplicationDetailResponse.from( + adminEventApplicationQueryUseCase.getApplicant(eventId, applicationId))); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java index e6399076..61e6df56 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java @@ -5,11 +5,13 @@ import java.util.Map; import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest; +import kr.ac.kookmin.stream.api.admin.event.event.request.EventPublishRequest; import kr.ac.kookmin.stream.api.admin.event.event.request.EventSearchParams; import kr.ac.kookmin.stream.api.admin.event.event.request.EventUpdateRequest; import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventDetailResponse; import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventListItemResponse; import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse; +import kr.ac.kookmin.stream.api.admin.event.event.response.EventPublishResponse; import kr.ac.kookmin.stream.api.admin.event.event.response.EventUpdateResponse; import kr.ac.kookmin.stream.api.admin.event.event.usecase.AdminEventWriteUseCase; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; @@ -22,8 +24,10 @@ import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.file.service.FileService; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -85,4 +89,22 @@ public ApiResponse updateEvent( Event event = adminEventWriteUseCase.update(eventId, request.toCommand()); return ApiResponse.success(EventUpdateResponse.from(event)); } + + @Override + @PatchMapping("/{eventId}/publish") + public ApiResponse changePublished( + AdminApiUser apiUser, + @PathVariable Long eventId, + @Valid @RequestBody EventPublishRequest request + ) { + Event event = eventService.changePublished(eventId, request.isPublished()); + return ApiResponse.success(EventPublishResponse.from(event)); + } + + @Override + @DeleteMapping("/{eventId}") + public ApiResponse deleteEvent(AdminApiUser apiUser, @PathVariable Long eventId) { + eventService.delete(eventId); + return ApiResponse.success(); + } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventApplicationSearchParams.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventApplicationSearchParams.java new file mode 100644 index 00000000..c999efba --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventApplicationSearchParams.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.api.admin.event.event.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; + +public record EventApplicationSearchParams( + @Schema(description = "이름 또는 학번 검색어", example = "김학생") + String keyword, + + @Schema(description = "신청 상태 필터. APPLIED(신청완료) 또는 CANCELED(취소). 없으면 전체", example = "APPLIED") + String status +) { + + // 빈 검색어는 검색하지 않는 것으로 본다 + public String toKeyword() { + return keyword == null || keyword.isBlank() ? null : keyword.strip(); + } + + public EventApplicationStatus toStatus() { + return EventApplicationStatus.from(status); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventPublishRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventPublishRequest.java new file mode 100644 index 00000000..2f7dc407 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventPublishRequest.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.api.admin.event.event.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record EventPublishRequest( + @Schema(description = "공개 여부. true면 학생 앱에 노출된다", example = "true") + @NotNull(message = "공개 여부를 입력해 주세요.") + Boolean isPublished +) { +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationDetailResponse.java new file mode 100644 index 00000000..f020c1b7 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationDetailResponse.java @@ -0,0 +1,73 @@ +package kr.ac.kookmin.stream.api.admin.event.event.response; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.api.admin.event.event.usecase.EventApplicantDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; + +/** + * 신청자 상세(폼 조회). 신청 현황 표에서 잘린 답변을 질문 전체와 함께 보여준다. + * + * @param name 탈퇴한 회원의 신청이면 {@code null} + * @param studentId 탈퇴한 회원의 신청이면 {@code null} + */ +public record AdminEventApplicationDetailResponse( + Long applicationId, + String name, + String studentId, + LocalDateTime appliedAt, + EventApplicationStatus status, + LocalDateTime canceledAt, + List questions +) { + + public static AdminEventApplicationDetailResponse from(EventApplicantDetail applicant) { + Member member = applicant.member(); + EventApplicationDetail detail = applicant.detail(); + return new AdminEventApplicationDetailResponse( + applicant.application().getId(), + member == null ? null : member.getName(), + member == null ? null : member.getStudentId(), + detail.appliedAt(), + detail.applicationStatus(), + detail.canceledAt(), + detail.questions().stream().map(AnsweredQuestion::from).toList() + ); + } + + /** + * 질문 한 건과 이 신청의 답변. 학생 앱 내 신청 상세와 같은 모양이다. + * + * @param answerText 텍스트형 답변. 선택형이거나 답하지 않았으면 {@code null} + * @param selectedOptions 고른 선택지의 0부터 시작하는 순서 번호. 텍스트형이거나 답하지 않았으면 빈 배열 + */ + public record AnsweredQuestion( + Long questionId, + String questionText, + QuestionType questionType, + boolean isRequired, + int displayOrder, + List options, + String answerText, + List selectedOptions + ) { + + public static AnsweredQuestion from(EventApplicationDetail.AnsweredQuestion answeredQuestion) { + EventQuestion question = answeredQuestion.question(); + return new AnsweredQuestion( + question.getId(), + question.getQuestionText(), + question.getQuestionType(), + question.isRequired(), + question.getDisplayOrder(), + question.getOptions() == null ? List.of() : question.getOptions(), + answeredQuestion.answerText(), + answeredQuestion.selectedOptions() + ); + } + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponse.java new file mode 100644 index 00000000..8f9c70ee --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponse.java @@ -0,0 +1,113 @@ +package kr.ac.kookmin.stream.api.admin.event.event.response; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import kr.ac.kookmin.stream.api.admin.event.event.usecase.EventApplicant; +import kr.ac.kookmin.stream.api.admin.event.event.usecase.EventApplicantPage; +import kr.ac.kookmin.stream.api.common.dto.PageResponse; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; + +/** + * 신청 현황 화면. 질문은 답변 열의 헤더이고, 신청 한 줄의 답변은 같은 순서로 담는다. + * + * @param totalApplicationCount 취소를 포함한 전체 신청 수. 검색·필터와 상관없다 + * @param appliedCount 신청완료 수. 검색·필터와 상관없다 + */ +public record AdminEventApplicationListResponse( + Long eventId, + String eventTitle, + long totalApplicationCount, + long appliedCount, + List questions, + PageResponse applications +) { + + public static AdminEventApplicationListResponse from(EventApplicantPage page) { + Event event = page.event().event(); + List questions = page.event().questions(); + return new AdminEventApplicationListResponse( + event.getId(), + event.getTitle(), + page.totalApplicationCount(), + page.event().applicantCount(), + questions.stream().map(Question::from).toList(), + PageResponse.from(page.applicants(), applicant -> Application.of(applicant, questions)) + ); + } + + public record Question(Long questionId, String questionText) { + + public static Question from(EventQuestion question) { + return new Question(question.getId(), question.getQuestionText()); + } + } + + /** + * @param name 탈퇴한 회원의 신청이면 {@code null} + * @param studentId 탈퇴한 회원의 신청이면 {@code null} + * @param answers {@code questions}와 같은 순서 + */ + public record Application( + Long applicationId, + String name, + String studentId, + LocalDateTime appliedAt, + EventApplicationStatus status, + List answers + ) { + + public static Application of(EventApplicant applicant, List questions) { + Member member = applicant.member(); + Map answerByQuestionId = applicant.answers().stream() + .collect(Collectors.toMap( + EventApplicationAnswer::getEventQuestionId, Function.identity(), (first, second) -> first)); + + return new Application( + applicant.application().getId(), + member == null ? null : member.getName(), + member == null ? null : member.getStudentId(), + applicant.application().getAppliedAt(), + applicant.application().getStatus(), + questions.stream() + .map(question -> Answer.of(question, answerByQuestionId.get(question.getId()))) + .toList() + ); + } + } + + /** + * @param displayText 표에 보일 답변. 답하지 않았으면 {@code null} + */ + public record Answer(Long questionId, String displayText) { + + /** + * 텍스트형은 입력한 답을, 선택형은 고른 선택지 문구를 쉼표로 이어 보여준다. + * + * @param answer 이 질문에 대한 답변. 답하지 않았으면 {@code null} + */ + public static Answer of(EventQuestion question, EventApplicationAnswer answer) { + return new Answer(question.getId(), answer == null ? null : displayTextOf(question, answer)); + } + + private static String displayTextOf(EventQuestion question, EventApplicationAnswer answer) { + if (!question.getQuestionType().isChoice()) { + return answer.getAnswerText(); + } + List selected = answer.getSelectedOptions() == null ? List.of() : answer.getSelectedOptions(); + List options = question.getOptions() == null ? List.of() : question.getOptions(); + // 선택지가 바뀌어 범위를 벗어난 인덱스는 건너뛴다. 신청이 있으면 질문을 못 바꾸지만 과거 데이터에 대비한다 + String text = selected.stream() + .filter(index -> index >= 0 && index < options.size()) + .map(options::get) + .collect(Collectors.joining(", ")); + return text.isEmpty() ? null : text; + } + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventPublishResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventPublishResponse.java new file mode 100644 index 00000000..2c3ec990 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventPublishResponse.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.api.admin.event.event.response; + +import kr.ac.kookmin.stream.event.domain.event.domain.Event; + +public record EventPublishResponse(Long eventId, boolean isPublished) { + + public static EventPublishResponse from(Event event) { + return new EventPublishResponse(event.getId(), event.isPublished()); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventApplicationQueryUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventApplicationQueryUseCase.java new file mode 100644 index 00000000..d686d866 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventApplicationQueryUseCase.java @@ -0,0 +1,90 @@ +package kr.ac.kookmin.stream.api.admin.event.event.usecase; + +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; +import kr.ac.kookmin.stream.event.domain.event.service.EventApplicationService; +import kr.ac.kookmin.stream.event.domain.event.service.EventService; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; +import kr.ac.kookmin.stream.member.domain.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 운영진 신청 현황. 신청은 행사(event) 도메인, 이름·학번은 회원(member) 도메인이라 이 UseCase가 조합한다. + * 조회만 하므로 트랜잭션으로 묶지 않는다. + */ +@Component +@RequiredArgsConstructor +public class AdminEventApplicationQueryUseCase { + + private final EventService eventService; + private final EventApplicationService eventApplicationService; + private final MemberService memberService; + + /** + * @param keyword 이름 또는 학번 검색어. {@code null}이면 검색하지 않는다 + * @param status {@code null}이면 상태로 거르지 않는다 + */ + public EventApplicantPage getApplicants( + Long eventId, + String keyword, + EventApplicationStatus status, + PageOffset pageOffset + ) { + EventManageDetail event = eventService.getEvent(eventId); + long totalApplicationCount = eventApplicationService.countApplications(eventId); + + PageResult applications = searchApplications(eventId, keyword, status, pageOffset); + List applicationIds = applications.content().stream().map(EventApplication::getId).toList(); + Map> answers = + eventApplicationService.getAnswersByApplicationIds(applicationIds); + // 탈퇴한 회원은 결과에서 빠지므로 그 회원의 신청은 member가 null로 남는다 + Map members = memberService.getMapByIds( + applications.content().stream().map(EventApplication::getMemberId).distinct().toList()); + + return new EventApplicantPage(event, totalApplicationCount, applications.map(application -> new EventApplicant( + application, + members.get(application.getMemberId()), + answers.getOrDefault(application.getId(), List.of()) + ))); + } + + public EventApplicantDetail getApplicant(Long eventId, Long applicationId) { + EventManageDetail event = eventService.getEvent(eventId); + EventApplication application = eventApplicationService.getApplication(eventId, applicationId); + List answers = eventApplicationService + .getAnswersByApplicationIds(List.of(applicationId)) + .getOrDefault(applicationId, List.of()); + Member member = memberService.getMapByIds(List.of(application.getMemberId())).get(application.getMemberId()); + + return new EventApplicantDetail( + application, + member, + EventApplicationDetail.of(event.event(), application, event.questions(), answers) + ); + } + + // 검색어에 맞는 회원이 없으면 신청을 조회할 필요도 없다 + private PageResult searchApplications( + Long eventId, + String keyword, + EventApplicationStatus status, + PageOffset pageOffset + ) { + if (keyword == null) { + return eventApplicationService.getApplications(eventId, status, null, pageOffset); + } + List memberIds = memberService.searchIdsByKeyword(keyword); + if (memberIds.isEmpty()) { + return PageResult.empty(pageOffset.page(), pageOffset.size()); + } + return eventApplicationService.getApplications(eventId, status, memberIds, pageOffset); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicant.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicant.java new file mode 100644 index 00000000..a846ba0a --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicant.java @@ -0,0 +1,16 @@ +package kr.ac.kookmin.stream.api.admin.event.event.usecase; + +import java.util.List; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; + +/** + * 신청 현황 한 줄을 만들 재료. 신청(event)과 신청자(member)가 서로 다른 도메인이라 UseCase가 짝지어 담는다. + * 화면용 가공은 응답 DTO가 한다. + * + * @param member 탈퇴(소프트 삭제)한 회원의 신청이면 {@code null} + * @param answers 이 신청에 저장된 답변. 답하지 않은 질문은 빠져 있다 + */ +public record EventApplicant(EventApplication application, Member member, List answers) { +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantDetail.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantDetail.java new file mode 100644 index 00000000..d073796e --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantDetail.java @@ -0,0 +1,13 @@ +package kr.ac.kookmin.stream.api.admin.event.event.usecase; + +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationDetail; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; + +/** + * 신청자 상세를 만들 재료. 질문과 답변 짝짓기는 학생 앱 내 신청 상세와 같은 {@link EventApplicationDetail}을 쓴다. + * + * @param member 탈퇴(소프트 삭제)한 회원의 신청이면 {@code null} + */ +public record EventApplicantDetail(EventApplication application, Member member, EventApplicationDetail detail) { +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantPage.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantPage.java new file mode 100644 index 00000000..6d907c50 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantPage.java @@ -0,0 +1,17 @@ +package kr.ac.kookmin.stream.api.admin.event.event.usecase; + +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; + +/** + * 신청 현황 화면을 만들 재료. 화면 제목·답변 열 헤더·신청 수는 행사 상세에서 가져온다. + * + * @param event 행사, 노출 순서대로 정렬된 질문, 유효(APPLIED) 신청자 수 + * @param totalApplicationCount 취소를 포함한 전체 신청 수. 검색·필터와 상관없다 + */ +public record EventApplicantPage( + EventManageDetail event, + long totalApplicationCount, + PageResult applicants +) { +} diff --git a/api/admin-api/src/test/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponseTest.java b/api/admin-api/src/test/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponseTest.java new file mode 100644 index 00000000..ca20e5a0 --- /dev/null +++ b/api/admin-api/src/test/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponseTest.java @@ -0,0 +1,149 @@ +package kr.ac.kookmin.stream.api.admin.event.event.response; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import kr.ac.kookmin.stream.api.admin.event.event.usecase.EventApplicant; +import kr.ac.kookmin.stream.api.admin.event.event.usecase.EventApplicantPage; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.common.Role; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import kr.ac.kookmin.stream.member.domain.member.domain.Department; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * 신청 현황 응답 조립. 특히 표에 보일 답변 문구(displayText)를 질문 유형별로 맞게 만드는지 본다. + */ +class AdminEventApplicationListResponseTest { + + private static final LocalDateTime APPLIED_AT = LocalDateTime.of(2026, 10, 3, 14, 22, 7); + + private static final EventQuestion PHONE = EventQuestion.of( + 201L, 15L, "전화번호", QuestionType.SHORT_TEXT, true, 1, List.of(), false); + private static final EventQuestion ATTEND = EventQuestion.of( + 202L, 15L, "참석 여부", QuestionType.SINGLE_CHOICE, true, 2, List.of("참석", "불참"), false); + private static final EventQuestion SPORTS = EventQuestion.of( + 203L, 15L, "참여 종목", QuestionType.MULTIPLE_CHOICE, false, 3, List.of("축구", "농구", "피구"), false); + + private static final Event EVENT = Event.of( + 15L, "2026학년도 새내기 배움터", "설명", "미래관", APPLIED_AT.plusDays(30), APPLIED_AT.plusDays(31), + APPLIED_AT.minusDays(7), APPLIED_AT.plusDays(10), RecruitType.FIRST_COME, List.of(), 200, + false, true, 1L, APPLIED_AT + ); + + private static Member member(String name, String studentId) { + return Member.of(101L, studentId, name, Department.SW, null, null, null, null, Role.STUDENT, null); + } + + private static EventApplicant applicant(Member member, List answers) { + EventApplication application = + EventApplication.of(11L, 15L, 101L, EventApplicationStatus.APPLIED, APPLIED_AT, null); + return new EventApplicant(application, member, answers); + } + + private static AdminEventApplicationListResponse responseOf(EventApplicant applicant) { + EventApplicantPage page = new EventApplicantPage( + new EventManageDetail(EVENT, List.of(PHONE, ATTEND, SPORTS), 2), + 3, + PageResult.of(List.of(applicant), 0, 20, 1) + ); + return AdminEventApplicationListResponse.from(page); + } + + private static List displayTextsOf(AdminEventApplicationListResponse response) { + return response.applications().content().getFirst().answers().stream() + .map(AdminEventApplicationListResponse.Answer::displayText) + .toList(); + } + + @Test + @DisplayName("화면 제목·신청 수·답변 열 헤더를 행사에서 가져온다") + void header() { + AdminEventApplicationListResponse response = responseOf(applicant(member("김학생", "20261234"), List.of())); + + assertEquals(15L, response.eventId()); + assertEquals("2026학년도 새내기 배움터", response.eventTitle()); + assertEquals(3, response.totalApplicationCount()); + assertEquals(2, response.appliedCount()); + assertEquals(List.of("전화번호", "참석 여부", "참여 종목"), + response.questions().stream().map(AdminEventApplicationListResponse.Question::questionText).toList()); + } + + @Test + @DisplayName("신청자 정보를 담는다") + void applicantInfo() { + AdminEventApplicationListResponse.Application row = + responseOf(applicant(member("김학생", "20261234"), List.of())).applications().content().getFirst(); + + assertEquals(11L, row.applicationId()); + assertEquals("김학생", row.name()); + assertEquals("20261234", row.studentId()); + assertEquals(APPLIED_AT, row.appliedAt()); + assertEquals(EventApplicationStatus.APPLIED, row.status()); + } + + @Test + @DisplayName("탈퇴한 회원의 신청은 이름·학번을 비운다") + void withdrawnMember() { + AdminEventApplicationListResponse.Application row = + responseOf(applicant(null, List.of())).applications().content().getFirst(); + + assertNull(row.name()); + assertNull(row.studentId()); + } + + @Nested + @DisplayName("표에 보일 답변 문구") + class DisplayText { + + @Test + @DisplayName("질문 순서대로, 텍스트형은 입력한 답을, 선택형은 고른 선택지 문구를 쉼표로 잇는다") + void formatsByQuestionType() { + List answers = List.of( + // 저장 순서가 질문 순서와 달라도 질문 순서로 맞춘다 + EventApplicationAnswer.of(3L, 11L, 203L, null, List.of(0, 2)), + EventApplicationAnswer.of(1L, 11L, 201L, "010-1234-5678", null), + EventApplicationAnswer.of(2L, 11L, 202L, null, List.of(1)) + ); + + assertEquals(List.of("010-1234-5678", "불참", "축구, 피구"), + displayTextsOf(responseOf(applicant(member("김학생", "20261234"), answers)))); + } + + @Test + @DisplayName("답하지 않은 질문은 null이다") + void unanswered() { + List answers = List.of( + EventApplicationAnswer.of(1L, 11L, 201L, "010-1234-5678", null)); + + assertEquals(Arrays.asList("010-1234-5678", null, null), + displayTextsOf(responseOf(applicant(member("김학생", "20261234"), answers)))); + } + + @Test + @DisplayName("범위를 벗어난 선택지 번호는 건너뛰고, 남는 게 없으면 null이다") + void skipsOutOfRangeOptions() { + List answers = List.of( + EventApplicationAnswer.of(2L, 11L, 202L, null, List.of(5)), + EventApplicationAnswer.of(3L, 11L, 203L, null, List.of(1, 9))); + + List texts = displayTextsOf(responseOf(applicant(member("김학생", "20261234"), answers))); + + assertNull(texts.get(1)); + assertEquals("농구", texts.get(2)); + } + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java index 6ab5f3e1..2d2c8e15 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java @@ -139,6 +139,11 @@ public Long thumbnailFileId() { return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst(); } + /** 학생 앱 노출 여부를 바꾼다. 이미 그 상태여도 그대로 둔다. */ + public void changePublished(boolean published) { + this.published = published; + } + /** * 운영진 화면에 보여줄 정원. 상시 모집은 인원 제한이 없어 {@code null}이다(저장값 0은 의미가 없다). */ diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatus.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatus.java index 373847eb..1fcfa2fb 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatus.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatus.java @@ -1,6 +1,23 @@ package kr.ac.kookmin.stream.event.domain.event.domain; +import kr.ac.kookmin.stream.common.BusinessException; + public enum EventApplicationStatus { APPLIED, - CANCELED + CANCELED; + + /** + * 신청 현황 필터로 들어온 문자열을 신청 상태로 바꾼다. 값이 없으면 필터를 걸지 않는다는 뜻이라 null을 돌려준다. + * 잘못된 값에 500이 나가지 않도록 여기서 걸러 BusinessException으로 바꾼다. + */ + public static EventApplicationStatus from(String value) { + if (value == null) { + return null; + } + try { + return EventApplicationStatus.valueOf(value); + } catch (IllegalArgumentException e) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_APPLICATION_STATUS); + } + } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java index 2d63c3c1..9f9cd94e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java @@ -19,5 +19,5 @@ public record EventCreateCommand( boolean feePaidOnly, List imageIds, List questions -) { +) implements EventWriteCommand { } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java index f978529e..2e97b22e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java @@ -21,6 +21,7 @@ public enum EventErrorCode implements ErrorCode { INVALID_ANSWER(ErrorStatus.BAD_REQUEST, "신청서 답변 형식이 올바르지 않습니다."), EVENT_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."), EVENT_INVALID_RECRUIT_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 모집 상태입니다."), + EVENT_INVALID_APPLICATION_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 신청 상태입니다."), EVENT_INVALID_PERIOD(ErrorStatus.BAD_REQUEST, "종료 일시는 시작 일시보다 늦어야 합니다."), EVENT_INVALID_CAPACITY(ErrorStatus.BAD_REQUEST, "선착순 모집은 1명 이상의 정원을 입력해야 합니다."), EVENT_INVALID_QUESTION(ErrorStatus.BAD_REQUEST, "선택형 질문에는 선택지를 1개 이상 입력해야 합니다."), diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java index 31ea49c9..7720da30 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java @@ -21,5 +21,5 @@ public record EventUpdateCommand( List imageIds, List questions, LocalDateTime expectedUpdatedAt -) { +) implements EventWriteCommand { } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventWriteCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventWriteCommand.java new file mode 100644 index 00000000..add0d14c --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventWriteCommand.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 행사 등록·수정 요청이 공통으로 갖는 행사 내용. 두 요청을 같은 규칙으로 검증하려고 둔다. + * {@link EventCreateCommand}와 {@link EventUpdateCommand}가 구현하며, record 컴포넌트가 그대로 이 메서드들이 된다. + */ +public interface EventWriteCommand { + + LocalDateTime eventStartAt(); + + LocalDateTime eventEndAt(); + + LocalDateTime applyStartAt(); + + LocalDateTime applyEndAt(); + + RecruitType recruitType(); + + /** 선착순 모집의 정원. 상시 모집이면 쓰이지 않는다 */ + Integer capacity(); + + List questions(); +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventApplicationRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventApplicationRepository.java new file mode 100644 index 00000000..93689122 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventApplicationRepository.java @@ -0,0 +1,74 @@ +package kr.ac.kookmin.stream.event.domain.event.repository; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; + +/** + * 행사 신청과 신청서 답변 저장소. 행사·신청서 질문은 {@link EventRepository}가 맡는다. + */ +public interface EventApplicationRepository { + + EventApplication save(EventApplication application); + + List saveAnswers(List answers); + + /** + * 행사별 유효 신청자 수를 한 번에 조회한다. 목록처럼 여러 행사의 신청자 수가 필요할 때 N+1을 피하기 위한 것으로, + * 신청이 한 건도 없는 행사는 결과에 담기지 않는다. + */ + Map countAppliedByEventIds(List eventIds); + + long countAppliedByEventId(Long eventId); + + /** 한 행사의 신청 수. 취소된 신청도 센다. */ + long countByEventId(Long eventId); + + /** + * 행사에 신청이 한 건이라도 있는지. 취소된 신청도 센다 — 취소돼도 답변이 질문 id·선택지 순서를 참조한다. + */ + boolean existsByEventId(Long eventId); + + boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId); + + /** + * 한 회원의 신청 내역을 신청 시각 내림차순(동일 시각은 applicationId 내림차순)으로 {@code limit}건까지 조회한다. + *

+ * 삭제된 행사의 신청은 제외한다. + *

+ * 페이지를 어디서 끊을지는 호출부가 정한다. 여기서는 요청한 개수만큼 돌려줄 뿐이다. + */ + List findSliceByMemberId(Long memberId, EventApplicationCursor cursor, int limit); + + /** + * 한 행사의 신청을 신청 시각 오름차순(동일 시각은 applicationId 오름차순)으로 페이지 조회한다. 취소된 신청도 담는다. + * + * @param status {@code null}이면 상태로 거르지 않는다 + * @param memberIds {@code null}이면 신청자로 거르지 않는다 + */ + PageResult findPageByEventId( + Long eventId, + EventApplicationStatus status, + List memberIds, + PageOffset pageOffset + ); + + /** + * 본인 신청 한 건을 조회한다. 남의 신청은 없는 것으로 보여야 하므로 회원 식별자를 조건에 함께 건다. + */ + Optional findByIdAndMemberId(Long applicationId, Long memberId); + + /** 한 행사에 속한 신청 한 건을 조회한다. 다른 행사의 신청은 없는 것으로 본다. */ + Optional findByIdAndEventId(Long applicationId, Long eventId); + + List findAnswersByApplicationId(Long applicationId); + + /** 여러 신청의 답변을 한 번에 조회한다. 신청마다 따로 읽으면 페이지 크기만큼 쿼리가 더 나간다. */ + List findAnswersByApplicationIds(List applicationIds); +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java index fb496043..2e84f5e7 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java @@ -2,34 +2,35 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Map; import java.util.Optional; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; -import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; -import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; -import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +/** + * 행사와 신청서 질문 저장소. 신청·답변은 {@link EventApplicationRepository}가 맡는다. + */ public interface EventRepository { Optional findById(Long id); Event save(Event event); + /** + * 행사를 삭제 표시한다(soft delete). 신청·답변은 남긴다. + * + * @return 삭제할 행사가 있었는지. 없거나 이미 삭제됐으면 {@code false} + */ + boolean delete(Long id); + List saveQuestions(List questions); /** 행사의 신청서 질문을 모두 지우고 주어진 질문으로 바꾼다. */ List replaceQuestions(Long eventId, List questions); - /** - * 행사에 신청이 한 건이라도 있는지. 취소된 신청도 센다 — 취소돼도 답변이 질문 id·선택지 순서를 참조한다. - */ - boolean existsApplicationByEventId(Long eventId); - /** * 행사의 수정 시각을 지금으로 갱신한다. 신청서 질문만 바뀌면 events 행의 값이 그대로라 * DB가 updated_at을 올리지 않으므로, 동시 수정 감지를 위해 직접 올린다. @@ -46,7 +47,7 @@ public interface EventRepository { /** * 주어진 식별자의 행사를 조회한다. 삭제 여부로 거르지 않는다. *

- * 내 신청 내역에서 신청마다 행사 정보를 붙일 때 쓴다. 삭제된 행사의 신청은 {@link #findApplicationSlice}가 + * 내 신청 내역에서 신청마다 행사 정보를 붙일 때 쓴다. 삭제된 행사의 신청은 {@link EventApplicationRepository#findSliceByMemberId}가 * 이미 걸러내므로 여기서 다시 거르지 않는다. 여기서 걸러내면 커서 페이지의 크기 계산이 어긋난다. */ List findAllByIds(List eventIds); @@ -69,35 +70,5 @@ List findPublishedSlice( LocalDateTime now ); - /** - * 행사별 유효 신청자 수를 한 번에 조회한다. 목록처럼 여러 행사의 신청자 수가 필요할 때 N+1을 피하기 위한 것으로, - * 신청이 한 건도 없는 행사는 결과에 담기지 않는다. - */ - Map countAppliedByEventIds(List eventIds); - List findQuestionsByEventId(Long eventId); - - long countAppliedByEventId(Long eventId); - - boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId); - - EventApplication saveApplication(EventApplication application); - - List saveAnswers(List answers); - - /** - * 한 회원의 신청 내역을 신청 시각 내림차순(동일 시각은 applicationId 내림차순)으로 {@code limit}건까지 조회한다. - *

- * 삭제된 행사의 신청은 제외한다. - *

- * 페이지를 어디서 끊을지는 호출부가 정한다. 여기서는 요청한 개수만큼 돌려줄 뿐이다. - */ - List findApplicationSlice(Long memberId, EventApplicationCursor cursor, int limit); - - /** - * 본인 신청 한 건을 조회한다. 남의 신청은 없는 것으로 보여야 하므로 회원 식별자를 조건에 함께 건다. - */ - Optional findApplicationByIdAndMemberId(Long applicationId, Long memberId); - - List findAnswersByApplicationId(Long applicationId); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventApplicationService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventApplicationService.java index 753b5125..8fc37a9a 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventApplicationService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventApplicationService.java @@ -1,9 +1,16 @@ package kr.ac.kookmin.stream.event.domain.event.service; +import java.util.List; +import java.util.Map; import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationForm; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationSummary; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; @@ -24,4 +31,28 @@ CursorSliceResult getMyApplications( EventApplicationDetail getMyApplication(Long applicationId, Long memberId); void cancelApplication(Long applicationId, Long memberId); + + /** + * 운영진 신청 현황. 한 행사의 신청을 신청 순으로 페이지 조회하며, 취소된 신청도 담는다. + * + * @param status {@code null}이면 상태로 거르지 않는다 + * @param memberIds {@code null}이면 신청자로 거르지 않는다 + */ + PageResult getApplications( + Long eventId, + EventApplicationStatus status, + List memberIds, + PageOffset pageOffset + ); + + /** 신청별 답변. 답변을 하나도 저장하지 않은 신청은 결과에 담기지 않는다. */ + Map> getAnswersByApplicationIds(List applicationIds); + + /** 한 행사의 신청 수. 취소된 신청도 센다. */ + long countApplications(Long eventId); + + /** + * 운영진 신청자 상세. 그 행사에 속한 신청만 조회한다. + */ + EventApplication getApplication(Long eventId, Long applicationId); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java index b6dfc05e..db872699 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java @@ -47,4 +47,12 @@ public interface EventService { * 선착순 정원을 현재 신청자 수보다 줄일 수 없다. */ Event update(Long eventId, EventUpdateCommand command); + + /** 학생 앱 노출 여부를 바꾼다. 이미 그 상태여도 성공으로 본다. */ + Event changePublished(Long eventId, boolean published); + + /** + * 행사를 삭제한다. 신청자가 있어도 지울 수 있으며, 삭제한 행사는 운영진 화면과 학생 앱 어디에도 보이지 않는다. + */ + void delete(Long eventId); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java index f77b88e0..ff117558 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java @@ -7,6 +7,8 @@ import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; @@ -14,11 +16,13 @@ import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationForm; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationSummary; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.repository.EventApplicationRepository; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; import kr.ac.kookmin.stream.event.domain.event.service.EventApplicationService; import lombok.RequiredArgsConstructor; @@ -30,6 +34,7 @@ class EventApplicationServiceImpl implements EventApplicationService { private final EventRepository eventRepository; + private final EventApplicationRepository eventApplicationRepository; private final EventApplyAnswerValidator eventApplyAnswerValidator; @Override @@ -44,17 +49,17 @@ public EventApplicationForm getApplicationForm(Long eventId) { public EventApplicationResult apply(Long eventId, Long memberId, EventApplyCommand command) { Event event = getOpenEvent(eventId); - if (eventRepository.existsAppliedByEventIdAndMemberId(eventId, memberId)) { + if (eventApplicationRepository.existsAppliedByEventIdAndMemberId(eventId, memberId)) { throw new BusinessException(EventErrorCode.ALREADY_APPLIED); } List questions = eventRepository.findQuestionsByEventId(eventId); eventApplyAnswerValidator.validate(questions, command); - EventApplication application = eventRepository.saveApplication( + EventApplication application = eventApplicationRepository.save( EventApplication.create(eventId, memberId, LocalDateTime.now()) ); - eventRepository.saveAnswers(toAnswers(application.getId(), command)); + eventApplicationRepository.saveAnswers(toAnswers(application.getId(), command)); return new EventApplicationResult(application.getId(), event); } @@ -67,7 +72,7 @@ public CursorSliceResult getMyApplications( int size ) { // 다음 페이지가 있는지 알려면 한 건 더 읽어봐야 한다 - List fetched = eventRepository.findApplicationSlice(memberId, cursor, size + 1); + List fetched = eventApplicationRepository.findSliceByMemberId(memberId, cursor, size + 1); // 행사는 한 번에 모아 조회한다. 신청마다 따로 읽으면 페이지 크기만큼 쿼리가 더 나간다 Map events = eventRepository.findAllByIds( @@ -87,15 +92,14 @@ public CursorSliceResult getMyApplications( @Transactional(readOnly = true) public EventApplicationDetail getMyApplication(Long applicationId, Long memberId) { EventApplication application = getMyApplicationOrThrow(applicationId, memberId); - Event event = eventRepository.findById(application.getEventId()) - .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); + Event event = getEventOrThrow(application.getEventId()); // 질문은 행사 것을 쓰고 답변만 이 신청 것을 붙인다. 취소 후 재신청이면 신청마다 답변이 따로 있다 return EventApplicationDetail.of( event, application, eventRepository.findQuestionsByEventId(event.getId()), - eventRepository.findAnswersByApplicationId(applicationId) + eventApplicationRepository.findAnswersByApplicationId(applicationId) ); } @@ -107,8 +111,7 @@ public void cancelApplication(Long applicationId, Long memberId) { throw new BusinessException(EventErrorCode.ALREADY_CANCELED); } - Event event = eventRepository.findById(application.getEventId()) - .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); + Event event = getEventOrThrow(application.getEventId()); // 마감 판정과 취소 시각이 같은 시각을 봐야 마감 직전 취소가 마감 이후로 기록되지 않는다 LocalDateTime now = LocalDateTime.now(); @@ -118,12 +121,46 @@ public void cancelApplication(Long applicationId, Long memberId) { } application.cancel(now); - eventRepository.saveApplication(application); + eventApplicationRepository.save(application); } /** 남의 신청은 없는 것으로 보여야 하므로 조회 단계에서 회원 식별자로 함께 거른다. */ + @Override + public PageResult getApplications( + Long eventId, + EventApplicationStatus status, + List memberIds, + PageOffset pageOffset + ) { + getEventOrThrow(eventId); + return eventApplicationRepository.findPageByEventId(eventId, status, memberIds, pageOffset); + } + + @Override + public Map> getAnswersByApplicationIds(List applicationIds) { + return eventApplicationRepository.findAnswersByApplicationIds(applicationIds).stream() + .collect(Collectors.groupingBy(EventApplicationAnswer::getEventApplicationId)); + } + + @Override + public long countApplications(Long eventId) { + return eventApplicationRepository.countByEventId(eventId); + } + + @Override + public EventApplication getApplication(Long eventId, Long applicationId) { + getEventOrThrow(eventId); + return eventApplicationRepository.findByIdAndEventId(applicationId, eventId) + .orElseThrow(() -> new BusinessException(EventErrorCode.APPLICATION_NOT_FOUND)); + } + + private Event getEventOrThrow(Long eventId) { + return eventRepository.findById(eventId) + .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); + } + private EventApplication getMyApplicationOrThrow(Long applicationId, Long memberId) { - return eventRepository.findApplicationByIdAndMemberId(applicationId, memberId) + return eventApplicationRepository.findByIdAndMemberId(applicationId, memberId) .orElseThrow(() -> new BusinessException(EventErrorCode.APPLICATION_NOT_FOUND)); } @@ -138,7 +175,7 @@ private Event getOpenEvent(Long eventId) { .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); LocalDateTime now = LocalDateTime.now(); - long appliedCount = eventRepository.countAppliedByEventId(eventId); + long appliedCount = eventApplicationRepository.countAppliedByEventId(eventId); if (event.calculateRecruitStatus(now, appliedCount) != RecruitStatus.OPEN) { throw new BusinessException(event.isClosedByCapacityOnly(now, appliedCount) ? EventErrorCode.CAPACITY_FULL diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java index 6842679e..895ea6a7 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java @@ -21,6 +21,7 @@ import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import kr.ac.kookmin.stream.event.domain.event.repository.EventApplicationRepository; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; import kr.ac.kookmin.stream.event.domain.event.service.EventService; import lombok.RequiredArgsConstructor; @@ -32,6 +33,7 @@ class EventServiceImpl implements EventService { private final EventRepository eventRepository; + private final EventApplicationRepository eventApplicationRepository; private final EventWriteValidator eventWriteValidator; @Override @@ -47,7 +49,7 @@ public CursorSliceResult getPublishedEvents( List fetched = eventRepository.findPublishedSlice(recruitStatus, cursor, size + 1, now); // 신청자 수는 한 번에 모아 조회한다. 행사마다 따로 세면 페이지 크기만큼 쿼리가 더 나간다 - Map applicantCounts = eventRepository.countAppliedByEventIds( + Map applicantCounts = eventApplicationRepository.countAppliedByEventIds( fetched.stream().limit(size).map(Event::getId).toList()); return CursorSliceResult.ofSlice( @@ -65,7 +67,7 @@ public EventDetail getPublishedEvent(Long eventId) { .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); LocalDateTime now = LocalDateTime.now(); - return EventDetail.of(event, eventRepository.countAppliedByEventId(eventId), now); + return EventDetail.of(event, eventApplicationRepository.countAppliedByEventId(eventId), now); } @Override @@ -87,7 +89,7 @@ public PageResult getEvents(String keyword, PageOffset pageOffset) { @Override @Transactional(readOnly = true) public Map getApplicantCounts(List eventIds) { - return eventRepository.countAppliedByEventIds(eventIds); + return eventApplicationRepository.countAppliedByEventIds(eventIds); } @Override @@ -97,7 +99,7 @@ public EventManageDetail getEvent(Long eventId) { return new EventManageDetail( event, eventRepository.findQuestionsByEventId(eventId), - eventRepository.countAppliedByEventId(eventId) + eventApplicationRepository.countAppliedByEventId(eventId) ); } @@ -112,7 +114,7 @@ public Event update(Long eventId, EventUpdateCommand command) { List questions = toQuestions(eventId, command.questions()); if (questionsChanged(eventRepository.findQuestionsByEventId(eventId), questions)) { // 답변이 질문 id·선택지 순서로 저장돼 있어 신청이 생긴 뒤 바꾸면 기존 답변의 뜻이 달라진다 - if (eventRepository.existsApplicationByEventId(eventId)) { + if (eventApplicationRepository.existsByEventId(eventId)) { throw new BusinessException(EventErrorCode.EVENT_QUESTIONS_LOCKED); } eventRepository.replaceQuestions(eventId, questions); @@ -123,6 +125,22 @@ public Event update(Long eventId, EventUpdateCommand command) { return eventRepository.save(event); } + @Override + @Transactional + public Event changePublished(Long eventId, boolean published) { + Event event = getEventOrThrow(eventId); + event.changePublished(published); + return eventRepository.save(event); + } + + @Override + @Transactional + public void delete(Long eventId) { + if (!eventRepository.delete(eventId)) { + throw new BusinessException(EventErrorCode.EVENT_NOT_FOUND); + } + } + private Event getEventOrThrow(Long eventId) { return eventRepository.findById(eventId) .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); @@ -139,7 +157,7 @@ private void validateNotModified(Event event, LocalDateTime expectedUpdatedAt) { // 이미 받은 신청을 정원 밖으로 밀어낼 수는 없다. 상시 모집은 인원 제한이 없어 보지 않는다 private void validateCapacityNotBelowApplicants(Long eventId, EventUpdateCommand command) { if (command.recruitType() == RecruitType.FIRST_COME - && command.capacity() < eventRepository.countAppliedByEventId(eventId)) { + && command.capacity() < eventApplicationRepository.countAppliedByEventId(eventId)) { throw new BusinessException(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS); } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java index 938c5547..9de699b3 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java @@ -3,10 +3,9 @@ import java.time.LocalDateTime; import java.util.List; import kr.ac.kookmin.stream.common.BusinessException; -import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; -import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventWriteCommand; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; import org.springframework.stereotype.Component; @@ -17,14 +16,7 @@ @Component class EventWriteValidator { - void validate(EventCreateCommand command) { - validatePeriod(command.eventStartAt(), command.eventEndAt()); - validatePeriod(command.applyStartAt(), command.applyEndAt()); - validateCapacity(command.recruitType(), command.capacity()); - validateQuestions(command.questions()); - } - - void validate(EventUpdateCommand command) { + void validate(EventWriteCommand command) { validatePeriod(command.eventStartAt(), command.eventEndAt()); validatePeriod(command.applyStartAt(), command.applyEndAt()); validateCapacity(command.recruitType(), command.capacity()); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatusTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatusTest.java new file mode 100644 index 00000000..78b4df81 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatusTest.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import kr.ac.kookmin.stream.common.BusinessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class EventApplicationStatusTest { + + @Test + @DisplayName("값이 없으면 필터를 걸지 않는다는 뜻이라 null을 돌려준다") + void nullMeansNoFilter() { + assertNull(EventApplicationStatus.from(null)); + } + + @ParameterizedTest + @DisplayName("정의된 이름은 그대로 바뀐다") + @EnumSource(EventApplicationStatus.class) + void parsesDefinedNames(EventApplicationStatus status) { + assertEquals(status, EventApplicationStatus.from(status.name())); + } + + @ParameterizedTest + @DisplayName("정의되지 않은 값은 500이 아니라 400으로 걸러진다") + @ValueSource(strings = {"applied", " APPLIED", "CANCELLED", "1", "-"}) + void rejectsUndefinedValues(String raw) { + BusinessException e = assertThrows(BusinessException.class, () -> EventApplicationStatus.from(raw)); + + assertEquals(EventErrorCode.EVENT_INVALID_APPLICATION_STATUS, e.getErrorCode()); + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java index 8c327aed..95cd4fb3 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java @@ -289,4 +289,20 @@ void openRecruitReturnsNull() { assertNull(event(RecruitType.OPEN).capacityLimit()); } } + + @Nested + @DisplayName("공개 여부 변경") + class ChangePublished { + + @Test + @DisplayName("공개 여부만 바꾼다") + void changesOnlyPublished() { + Event event = firstComeEvent(); + + event.changePublished(false); + + assertFalse(event.isPublished()); + assertEquals("2026 소융대 개강 행사", event.getTitle()); + } + } } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImplManageTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImplManageTest.java new file mode 100644 index 00000000..3c0ce53d --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImplManageTest.java @@ -0,0 +1,164 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * 운영진 신청 현황·신청자 상세 조회. 신청자 이름·학번 조합은 api 계층 UseCase가 맡으므로 여기서는 신청 조회 규칙만 본다. + */ +class EventApplicationServiceImplManageTest { + + private static final Long EVENT_ID = 15L; + private static final Long OTHER_EVENT_ID = 16L; + private static final LocalDateTime APPLIED_AT = LocalDateTime.of(2026, 10, 3, 14, 0); + + private final InMemoryEventRepository repository = new InMemoryEventRepository(); + private final InMemoryEventApplicationRepository applicationRepository = new InMemoryEventApplicationRepository(); + private final EventApplicationServiceImpl service = + new EventApplicationServiceImpl(repository, applicationRepository, new EventApplyAnswerValidator()); + + private static Event event(Long id) { + return Event.of( + id, "새내기 배움터", "설명", "미래관", APPLIED_AT.plusDays(30), APPLIED_AT.plusDays(31), + APPLIED_AT.minusDays(7), APPLIED_AT.plusDays(10), RecruitType.FIRST_COME, List.of(), 200, + false, true, 1L, APPLIED_AT + ); + } + + private void givenApplication(Long id, Long eventId, Long memberId, EventApplicationStatus status, + LocalDateTime appliedAt) { + applicationRepository.applications.put(id, EventApplication.of(id, eventId, memberId, status, appliedAt, + status == EventApplicationStatus.CANCELED ? appliedAt.plusHours(1) : null)); + } + + private static List idsOf(PageResult page) { + return page.content().stream().map(EventApplication::getId).toList(); + } + + private static void assertRejected(EventErrorCode expected, Runnable action) { + BusinessException e = assertThrows(BusinessException.class, action::run); + assertEquals(expected, e.getErrorCode()); + } + + @BeforeEach + void setUp() { + repository.events.put(EVENT_ID, event(EVENT_ID)); + repository.events.put(OTHER_EVENT_ID, event(OTHER_EVENT_ID)); + // 신청 순서와 id 순서를 일부러 엇갈리게 둔다 + givenApplication(11L, EVENT_ID, 101L, EventApplicationStatus.APPLIED, APPLIED_AT.plusDays(1)); + givenApplication(12L, EVENT_ID, 102L, EventApplicationStatus.APPLIED, APPLIED_AT); + givenApplication(13L, EVENT_ID, 103L, EventApplicationStatus.CANCELED, APPLIED_AT.plusDays(2)); + givenApplication(14L, OTHER_EVENT_ID, 101L, EventApplicationStatus.APPLIED, APPLIED_AT); + } + + @Nested + @DisplayName("신청 현황") + class GetApplications { + + @Test + @DisplayName("그 행사의 신청만, 신청 순으로, 취소된 신청까지 담는다") + void ordersByAppliedAtAndKeepsCanceled() { + PageResult page = service.getApplications(EVENT_ID, null, null, PageOffset.of(0, 20)); + + assertEquals(List.of(12L, 11L, 13L), idsOf(page)); + assertEquals(3, page.totalCount()); + } + + @Test + @DisplayName("신청 상태로 거른다") + void filtersByStatus() { + assertEquals(List.of(12L, 11L), idsOf(service.getApplications( + EVENT_ID, EventApplicationStatus.APPLIED, null, PageOffset.of(0, 20)))); + assertEquals(List.of(13L), idsOf(service.getApplications( + EVENT_ID, EventApplicationStatus.CANCELED, null, PageOffset.of(0, 20)))); + } + + @Test + @DisplayName("신청자(검색된 회원)로 거른다") + void filtersByMembers() { + assertEquals(List.of(11L, 13L), idsOf(service.getApplications( + EVENT_ID, null, List.of(101L, 103L), PageOffset.of(0, 20)))); + } + + @Test + @DisplayName("페이지 단위로 자른다") + void pages() { + PageResult secondPage = + service.getApplications(EVENT_ID, null, null, PageOffset.of(1, 2)); + + assertEquals(List.of(13L), idsOf(secondPage)); + assertEquals(3, secondPage.totalCount()); + assertEquals(2, secondPage.totalPage()); + } + + @Test + @DisplayName("없거나 삭제된 행사는 찾을 수 없다") + void eventNotFound() { + assertRejected(EventErrorCode.EVENT_NOT_FOUND, + () -> service.getApplications(99L, null, null, PageOffset.of(0, 20))); + } + } + + @Test + @DisplayName("전체 신청 수는 취소된 신청도 센다") + void countsCanceledApplications() { + assertEquals(3, service.countApplications(EVENT_ID)); + } + + @Test + @DisplayName("답변은 신청별로 묶어 돌려주고, 답변이 없는 신청은 담지 않는다") + void groupsAnswersByApplication() { + applicationRepository.answers.add(EventApplicationAnswer.of(1L, 11L, 201L, "010-1234-5678", null)); + applicationRepository.answers.add(EventApplicationAnswer.of(2L, 11L, 202L, null, List.of(0))); + applicationRepository.answers.add(EventApplicationAnswer.of(3L, 12L, 201L, "010-2345-6789", null)); + + Map> answers = service.getAnswersByApplicationIds(List.of(11L, 12L, 13L)); + + assertEquals(2, answers.get(11L).size()); + assertEquals(1, answers.get(12L).size()); + assertFalse(answers.containsKey(13L)); + } + + @Nested + @DisplayName("신청자 상세") + class GetApplication { + + @Test + @DisplayName("그 행사의 신청을 돌려준다") + void returnsApplication() { + assertEquals(103L, service.getApplication(EVENT_ID, 13L).getMemberId()); + } + + @Test + @DisplayName("다른 행사의 신청이면 찾을 수 없다") + void otherEventsApplication() { + assertRejected(EventErrorCode.APPLICATION_NOT_FOUND, () -> service.getApplication(EVENT_ID, 14L)); + } + + @Test + @DisplayName("없거나 삭제된 행사는 신청보다 먼저 걸러진다") + void eventNotFound() { + repository.events.remove(EVENT_ID); + + assertRejected(EventErrorCode.EVENT_NOT_FOUND, () -> service.getApplication(EVENT_ID, 11L)); + } + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java index b4285436..07663164 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java @@ -10,19 +10,13 @@ import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; -import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; -import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; -import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; -import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; @@ -31,7 +25,6 @@ import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; -import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -55,7 +48,9 @@ class EventServiceImplManageTest { "전화번호", QuestionType.SHORT_TEXT, true, List.of(), false); private final InMemoryEventRepository repository = new InMemoryEventRepository(); - private final EventServiceImpl service = new EventServiceImpl(repository, new EventWriteValidator()); + private final InMemoryEventApplicationRepository applicationRepository = new InMemoryEventApplicationRepository(); + private final EventServiceImpl service = + new EventServiceImpl(repository, applicationRepository, new EventWriteValidator()); private static Event savedEvent(RecruitType recruitType, int capacity) { return Event.of( @@ -156,7 +151,7 @@ class GetEvent { void returnsBundle() { repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100)); repository.questions.put(EVENT_ID, savedQuestions(PHONE, SPORTS)); - repository.appliedCounts.put(EVENT_ID, 3L); + applicationRepository.appliedCounts.put(EVENT_ID, 3L); EventManageDetail detail = service.getEvent(EVENT_ID); @@ -244,7 +239,7 @@ class CapacityFloor { @DisplayName("선착순 정원을 현재 신청자 수보다 줄일 수 없다") void rejectsBelowApplicants() { givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); - repository.appliedCounts.put(EVENT_ID, 10L); + applicationRepository.appliedCounts.put(EVENT_ID, 10L); assertRejected(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS, () -> service.update(EVENT_ID, updateCommand(RecruitType.FIRST_COME, 9, List.of(SPORTS), UPDATED_AT))); @@ -254,7 +249,7 @@ void rejectsBelowApplicants() { @DisplayName("신청자 수와 같은 정원까지는 줄일 수 있다") void allowsEqualToApplicants() { givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); - repository.appliedCounts.put(EVENT_ID, 10L); + applicationRepository.appliedCounts.put(EVENT_ID, 10L); service.update(EVENT_ID, updateCommand(RecruitType.FIRST_COME, 10, List.of(SPORTS), UPDATED_AT)); @@ -265,7 +260,7 @@ void allowsEqualToApplicants() { @DisplayName("상시 모집에서 선착순으로 바꿀 때도 신청자 수 이상이어야 한다") void switchingToFirstComeChecksApplicants() { givenEvent(RecruitType.OPEN, 0, savedQuestions(SPORTS)); - repository.appliedCounts.put(EVENT_ID, 30L); + applicationRepository.appliedCounts.put(EVENT_ID, 30L); assertRejected(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS, () -> service.update(EVENT_ID, updateCommand(RecruitType.FIRST_COME, 20, List.of(SPORTS), UPDATED_AT))); @@ -275,7 +270,7 @@ void switchingToFirstComeChecksApplicants() { @DisplayName("상시 모집은 신청자 수와 상관없이 수정할 수 있다") void openRecruitIgnoresApplicants() { givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); - repository.appliedCounts.put(EVENT_ID, 300L); + applicationRepository.appliedCounts.put(EVENT_ID, 300L); assertDoesNotThrow(() -> service.update(EVENT_ID, updateCommand(RecruitType.OPEN, null, List.of(SPORTS), UPDATED_AT))); @@ -291,7 +286,7 @@ class Questions { void keepsQuestionsWhenUnchanged() { List original = savedQuestions(PHONE, SPORTS); givenEvent(RecruitType.FIRST_COME, 100, original); - repository.applicationExists = true; + applicationRepository.applicationExists = true; service.update(EVENT_ID, updateCommand(List.of(PHONE, SPORTS))); @@ -338,7 +333,7 @@ void reorderCountsAsChange() { @DisplayName("신청(취소 포함)이 있으면 질문을 바꿀 수 없고 행사도 저장하지 않는다") void lockedWhenApplicationExists() { givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); - repository.applicationExists = true; + applicationRepository.applicationExists = true; assertRejected(EventErrorCode.EVENT_QUESTIONS_LOCKED, () -> service.update(EVENT_ID, updateCommand(List.of(SPORTS, PHONE)))); @@ -367,8 +362,8 @@ void delegatesToRepository() { @Test @DisplayName("신청자 수는 행사 id 목록으로 한 번에 조회한다") void countsApplicantsAtOnce() { - repository.appliedCounts.put(1L, 3L); - repository.appliedCounts.put(2L, 5L); + applicationRepository.appliedCounts.put(1L, 3L); + applicationRepository.appliedCounts.put(2L, 5L); assertEquals(Map.of(1L, 3L, 2L, 5L), service.getApplicantCounts(List.of(1L, 2L))); } @@ -379,7 +374,7 @@ void countsApplicantsAtOnce() { void detailApplicantCountFeedsRecruitStatus() { repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 3)); repository.questions.put(EVENT_ID, List.of()); - repository.appliedCounts.put(EVENT_ID, 3L); + applicationRepository.appliedCounts.put(EVENT_ID, 3L); EventManageDetail detail = service.getEvent(EVENT_ID); @@ -387,129 +382,65 @@ void detailApplicantCountFeedsRecruitStatus() { detail.event().calculateRecruitStatus(APPLY_START_AT.plusDays(1), detail.applicantCount())); } - /** 운영진 등록·조회·수정에 쓰는 메서드만 메모리로 흉내 내고, 나머지는 이 테스트가 건드리지 않는다. */ - private static final class InMemoryEventRepository implements EventRepository { - - private final Map events = new HashMap<>(); - private final Map> questions = new HashMap<>(); - private final Map appliedCounts = new HashMap<>(); - private boolean applicationExists; - private boolean questionsReplaced; - private boolean updatedAtTouched; - private String requestedKeyword; - private PageOffset requestedPageOffset; - private long nextId = 1; - - @Override - public Optional findById(Long id) { - return Optional.ofNullable(events.get(id)); - } - - @Override - public Event save(Event event) { - Long id = event.getId() == null ? nextId++ : event.getId(); - Event saved = Event.of( - id, event.getTitle(), event.getDescription(), event.getPlace(), event.getEventStartAt(), - event.getEventEndAt(), event.getApplyStartAt(), event.getApplyEndAt(), event.getRecruitType(), - event.getImageIds(), event.getCapacity(), event.isFeePaidOnly(), event.isPublished(), - event.getCreatedBy(), event.getUpdatedAt()); - events.put(id, saved); - return saved; - } - - @Override - public List saveQuestions(List saved) { - saved.forEach(question -> - questions.computeIfAbsent(question.getEventId(), key -> new ArrayList<>()).add(question)); - return saved; - } - - @Override - public List replaceQuestions(Long eventId, List replaced) { - questionsReplaced = true; - questions.put(eventId, replaced); - return replaced; - } - - @Override - public boolean existsApplicationByEventId(Long eventId) { - return applicationExists; - } - - @Override - public void touchUpdatedAt(Long eventId) { - updatedAtTouched = true; - } + @Nested + @DisplayName("공개 상태 변경") + class ChangePublished { - @Override - public PageResult findPage(String keyword, PageOffset pageOffset) { - requestedKeyword = keyword; - requestedPageOffset = pageOffset; - List content = List.copyOf(events.values()); - return PageResult.of(content, pageOffset.page(), pageOffset.size(), content.size()); - } + @Test + @DisplayName("공개 여부를 바꿔 저장하고 다른 내용은 그대로 둔다") + void changesOnlyPublished() { + repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100)); - @Override - public List findQuestionsByEventId(Long eventId) { - return questions.getOrDefault(eventId, List.of()); - } + Event changed = service.changePublished(EVENT_ID, false); - @Override - public long countAppliedByEventId(Long eventId) { - return appliedCounts.getOrDefault(eventId, 0L); + assertFalse(changed.isPublished()); + assertFalse(repository.events.get(EVENT_ID).isPublished()); + assertEquals("체육대회", repository.events.get(EVENT_ID).getTitle()); } - @Override - public Map countAppliedByEventIds(List eventIds) { - Map counts = new HashMap<>(); - eventIds.stream().filter(appliedCounts::containsKey).forEach(id -> counts.put(id, appliedCounts.get(id))); - return counts; - } + @Test + @DisplayName("이미 그 상태여도 성공으로 본다") + void idempotent() { + repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100)); - @Override - public List findAllByIds(List eventIds) { - throw new UnsupportedOperationException(); + assertTrue(service.changePublished(EVENT_ID, true).isPublished()); } - @Override - public Optional findPublishedById(Long id) { - throw new UnsupportedOperationException(); + @Test + @DisplayName("없거나 삭제된 행사는 찾을 수 없다") + void notFound() { + assertRejected(EventErrorCode.EVENT_NOT_FOUND, () -> service.changePublished(EVENT_ID, true)); } + } - @Override - public List findPublishedSlice( - RecruitStatus recruitStatus, EventCursor cursor, int limit, LocalDateTime now) { - throw new UnsupportedOperationException(); - } + @Nested + @DisplayName("삭제") + class Delete { - @Override - public boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId) { - throw new UnsupportedOperationException(); - } + @Test + @DisplayName("행사를 지운다") + void deletes() { + repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100)); - @Override - public EventApplication saveApplication(EventApplication application) { - throw new UnsupportedOperationException(); - } + service.delete(EVENT_ID); - @Override - public List saveAnswers(List answers) { - throw new UnsupportedOperationException(); + assertTrue(repository.findById(EVENT_ID).isEmpty()); } - @Override - public List findApplicationSlice(Long memberId, EventApplicationCursor cursor, int limit) { - throw new UnsupportedOperationException(); - } + @Test + @DisplayName("신청자가 있어도 지울 수 있다") + void deletesEvenWithApplications() { + repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100)); + applicationRepository.applicationExists = true; + applicationRepository.appliedCounts.put(EVENT_ID, 12L); - @Override - public Optional findApplicationByIdAndMemberId(Long applicationId, Long memberId) { - throw new UnsupportedOperationException(); + assertDoesNotThrow(() -> service.delete(EVENT_ID)); } - @Override - public List findAnswersByApplicationId(Long applicationId) { - throw new UnsupportedOperationException(); + @Test + @DisplayName("없거나 이미 삭제된 행사는 찾을 수 없다") + void notFound() { + assertRejected(EventErrorCode.EVENT_NOT_FOUND, () -> service.delete(EVENT_ID)); } } } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java index dc7afcef..6d10e42c 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java @@ -16,11 +16,13 @@ import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import kr.ac.kookmin.stream.event.domain.event.repository.EventApplicationRepository; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -47,7 +49,7 @@ void matchesApplicantCountToItsEvent() { List.of(event(1L), event(2L)), Map.of(1L, 3L, 2L, 7L)); - List content = new EventServiceImpl(repository, new EventWriteValidator()) + List content = new EventServiceImpl(repository, repository, new EventWriteValidator()) .getPublishedEvents(null, null, 20).content(); // 정원 100명이라 신청자 수가 모집 상태를 바꾸지 않는다. 짝이 맞는지는 D-Day가 아니라 아래에서 직접 본다 @@ -63,7 +65,7 @@ void keepsEventsWithoutApplicants() { List.of(event(1L), event(2L)), Map.of(1L, 3L)); - List content = new EventServiceImpl(repository, new EventWriteValidator()) + List content = new EventServiceImpl(repository, repository, new EventWriteValidator()) .getPublishedEvents(null, null, 20).content(); assertEquals(2, content.size()); @@ -86,7 +88,7 @@ void appliesCountPerEvent() { List.of(full, roomy), Map.of(1L, 3L, 2L, 3L)); - List content = new EventServiceImpl(repository, new EventWriteValidator()) + List content = new EventServiceImpl(repository, repository, new EventWriteValidator()) .getPublishedEvents(null, null, 20).content(); assertEquals(RecruitStatus.CLOSED, content.get(0).recruitStatus()); @@ -99,7 +101,7 @@ void detectsNextPage() { FakeEventRepository repository = new FakeEventRepository( List.of(event(1L), event(2L), event(3L)), Map.of()); - CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator()) + CursorSliceResult result = new EventServiceImpl(repository, repository, new EventWriteValidator()) .getPublishedEvents(null, null, 2); assertEquals(3, repository.requestedLimit); @@ -113,7 +115,7 @@ void detectsNextPage() { void noCursorOnLastPage() { FakeEventRepository repository = new FakeEventRepository(List.of(event(1L), event(2L)), Map.of()); - CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator()) + CursorSliceResult result = new EventServiceImpl(repository, repository, new EventWriteValidator()) .getPublishedEvents(null, null, 2); assertFalse(result.hasNext()); @@ -126,15 +128,15 @@ void asksNothingWhenSliceIsEmpty() { FakeEventRepository repository = new FakeEventRepository( List.of(), Map.of()); - CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator()) + CursorSliceResult result = new EventServiceImpl(repository, repository, new EventWriteValidator()) .getPublishedEvents(null, null, 20); assertTrue(result.content().isEmpty()); assertEquals(List.of(), repository.requestedEventIds); } - /** 목록 조회에 쓰이는 두 메서드만 답하고, 나머지는 이 테스트가 건드리지 않는다. */ - private static final class FakeEventRepository implements EventRepository { + /** 목록 조회에 쓰이는 두 메서드(행사 조회·신청자 수)만 답하고, 나머지는 이 테스트가 건드리지 않는다. */ + private static final class FakeEventRepository implements EventRepository, EventApplicationRepository { private final List fetched; private final Map applicantCounts; @@ -174,6 +176,11 @@ public List saveQuestions(List questions) { throw new UnsupportedOperationException(); } + @Override + public boolean delete(Long id) { + throw new UnsupportedOperationException(); + } + @Override public PageResult findPage(String keyword, PageOffset pageOffset) { throw new UnsupportedOperationException(); @@ -185,7 +192,7 @@ public List replaceQuestions(Long eventId, List qu } @Override - public boolean existsApplicationByEventId(Long eventId) { + public boolean existsByEventId(Long eventId) { throw new UnsupportedOperationException(); } @@ -215,7 +222,7 @@ public boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId) { } @Override - public EventApplication saveApplication(EventApplication application) { + public EventApplication save(EventApplication application) { throw new UnsupportedOperationException(); } @@ -230,13 +237,13 @@ public List findAllByIds(List eventIds) { } @Override - public List findApplicationSlice( + public List findSliceByMemberId( Long memberId, EventApplicationCursor cursor, int limit) { throw new UnsupportedOperationException(); } @Override - public Optional findApplicationByIdAndMemberId( + public Optional findByIdAndMemberId( Long applicationId, Long memberId) { throw new UnsupportedOperationException(); } @@ -245,5 +252,26 @@ public Optional findApplicationByIdAndMemberId( public List findAnswersByApplicationId(Long applicationId) { throw new UnsupportedOperationException(); } + + @Override + public PageResult findPageByEventId( + Long eventId, EventApplicationStatus status, List memberIds, PageOffset pageOffset) { + throw new UnsupportedOperationException(); + } + + @Override + public List findAnswersByApplicationIds(List applicationIds) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional findByIdAndEventId(Long applicationId, Long eventId) { + throw new UnsupportedOperationException(); + } + + @Override + public long countByEventId(Long eventId) { + throw new UnsupportedOperationException(); + } } } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/InMemoryEventApplicationRepository.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/InMemoryEventApplicationRepository.java new file mode 100644 index 00000000..81379382 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/InMemoryEventApplicationRepository.java @@ -0,0 +1,113 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import kr.ac.kookmin.stream.event.domain.event.repository.EventApplicationRepository; + +/** + * 운영진 행사 관리 테스트용 신청 메모리 레포지토리. 운영진 기능이 쓰는 메서드만 흉내 내고, + * 학생 신청·내 신청 내역 쪽 메서드는 이 테스트들이 건드리지 않으므로 막아 둔다. + */ +class InMemoryEventApplicationRepository implements EventApplicationRepository { + + final Map appliedCounts = new HashMap<>(); + final Map applications = new HashMap<>(); + final List answers = new ArrayList<>(); + /** 질문 잠금 판정용. 신청 데이터를 넣지 않고도 "신청 있음"을 만들 수 있게 따로 둔다 */ + boolean applicationExists; + + @Override + public boolean existsByEventId(Long eventId) { + return applicationExists; + } + + @Override + public long countAppliedByEventId(Long eventId) { + return appliedCounts.getOrDefault(eventId, 0L); + } + + @Override + public Map countAppliedByEventIds(List eventIds) { + Map counts = new HashMap<>(); + eventIds.stream().filter(appliedCounts::containsKey).forEach(id -> counts.put(id, appliedCounts.get(id))); + return counts; + } + + @Override + public PageResult findPageByEventId( + Long eventId, + EventApplicationStatus status, + List memberIds, + PageOffset pageOffset + ) { + List matched = applications.values().stream() + .filter(application -> application.getEventId().equals(eventId)) + .filter(application -> status == null || application.getStatus() == status) + .filter(application -> memberIds == null || memberIds.contains(application.getMemberId())) + .sorted(Comparator.comparing(EventApplication::getAppliedAt).thenComparing(EventApplication::getId)) + .toList(); + List content = matched.stream() + .skip((long) pageOffset.page() * pageOffset.size()) + .limit(pageOffset.size()) + .toList(); + return PageResult.of(content, pageOffset.page(), pageOffset.size(), matched.size()); + } + + @Override + public List findAnswersByApplicationIds(List applicationIds) { + return answers.stream() + .filter(answer -> applicationIds.contains(answer.getEventApplicationId())) + .toList(); + } + + @Override + public Optional findByIdAndEventId(Long applicationId, Long eventId) { + return Optional.ofNullable(applications.get(applicationId)) + .filter(application -> application.getEventId().equals(eventId)); + } + + @Override + public long countByEventId(Long eventId) { + return applications.values().stream().filter(application -> application.getEventId().equals(eventId)).count(); + } + + @Override + public boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId) { + throw new UnsupportedOperationException(); + } + + @Override + public EventApplication save(EventApplication application) { + throw new UnsupportedOperationException(); + } + + @Override + public List saveAnswers(List saved) { + throw new UnsupportedOperationException(); + } + + @Override + public List findSliceByMemberId(Long memberId, EventApplicationCursor cursor, int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional findByIdAndMemberId(Long applicationId, Long memberId) { + throw new UnsupportedOperationException(); + } + + @Override + public List findAnswersByApplicationId(Long applicationId) { + throw new UnsupportedOperationException(); + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/InMemoryEventRepository.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/InMemoryEventRepository.java new file mode 100644 index 00000000..aa172f08 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/InMemoryEventRepository.java @@ -0,0 +1,99 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; + +/** + * 운영진 행사 관리 테스트용 행사 메모리 레포지토리. 운영진 기능이 쓰는 메서드만 흉내 내고, + * 학생 목록 쪽 메서드는 이 테스트들이 건드리지 않으므로 막아 둔다. + */ +class InMemoryEventRepository implements EventRepository { + + final Map events = new HashMap<>(); + final Map> questions = new HashMap<>(); + boolean questionsReplaced; + boolean updatedAtTouched; + String requestedKeyword; + PageOffset requestedPageOffset; + private long nextId = 1; + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(events.get(id)); + } + + @Override + public Event save(Event event) { + Long id = event.getId() == null ? nextId++ : event.getId(); + Event saved = Event.of( + id, event.getTitle(), event.getDescription(), event.getPlace(), event.getEventStartAt(), + event.getEventEndAt(), event.getApplyStartAt(), event.getApplyEndAt(), event.getRecruitType(), + event.getImageIds(), event.getCapacity(), event.isFeePaidOnly(), event.isPublished(), + event.getCreatedBy(), event.getUpdatedAt()); + events.put(id, saved); + return saved; + } + + @Override + public boolean delete(Long id) { + return events.remove(id) != null; + } + + @Override + public List saveQuestions(List saved) { + saved.forEach(question -> + questions.computeIfAbsent(question.getEventId(), key -> new ArrayList<>()).add(question)); + return saved; + } + + @Override + public List replaceQuestions(Long eventId, List replaced) { + questionsReplaced = true; + questions.put(eventId, replaced); + return replaced; + } + + @Override + public void touchUpdatedAt(Long eventId) { + updatedAtTouched = true; + } + + @Override + public PageResult findPage(String keyword, PageOffset pageOffset) { + requestedKeyword = keyword; + requestedPageOffset = pageOffset; + List content = List.copyOf(events.values()); + return PageResult.of(content, pageOffset.page(), pageOffset.size(), content.size()); + } + + @Override + public List findQuestionsByEventId(Long eventId) { + return questions.getOrDefault(eventId, List.of()); + } + + @Override + public List findAllByIds(List eventIds) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional findPublishedById(Long id) { + throw new UnsupportedOperationException(); + } + + @Override + public List findPublishedSlice(RecruitStatus recruitStatus, EventCursor cursor, int limit, LocalDateTime now) { + throw new UnsupportedOperationException(); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationAnswerJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationAnswerJpaRepository.java index ca9b05d2..91731be0 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationAnswerJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationAnswerJpaRepository.java @@ -7,4 +7,6 @@ public interface EventApplicationAnswerJpaRepository extends JpaRepository { List findAllByEventApplicationId(Long eventApplicationId); + + List findAllByEventApplicationIdIn(List eventApplicationIds); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java index ff405012..04eac58f 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -15,10 +16,37 @@ public interface EventApplicationJpaRepository extends JpaRepository searchByEventId( + @Param("eventId") Long eventId, + @Param("status") EventApplicationStatus status, + @Param("filterByMember") boolean filterByMember, + @Param("memberIds") List memberIds, + Pageable pageable + ); + boolean existsByEventIdAndMemberIdAndStatus(Long eventId, Long memberId, EventApplicationStatus status); Optional findByIdAndMemberId(Long id, Long memberId); + Optional findByIdAndEventId(Long id, Long eventId); + /** * 한 회원의 신청 내역을 (applied_at, event_application_id) keyset 커서로 최신순 조회한다. * 취소된 신청도 이력으로 함께 내려간다. 삭제된 행사의 신청은 신청 내역과 함께 지운 것으로 보고 제외한다. @@ -38,4 +66,15 @@ List findSliceByMemberId( @Param("cursorApplicationId") Long cursorApplicationId, Pageable pageable ); + + @Query(""" + SELECT new kr.ac.kookmin.stream.db.event.EventApplicantCountProjection(a.eventId, COUNT(a)) + FROM EventApplicationJpaEntity a + WHERE a.eventId IN :eventIds AND a.status = :appliedStatus + GROUP BY a.eventId + """) + List countApplicantsByEventIds( + @Param("eventIds") List eventIds, + @Param("appliedStatus") EventApplicationStatus appliedStatus + ); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationRepositoryImpl.java new file mode 100644 index 00000000..67ad1b36 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationRepositoryImpl.java @@ -0,0 +1,135 @@ +package kr.ac.kookmin.stream.db.event; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import kr.ac.kookmin.stream.event.domain.event.repository.EventApplicationRepository; +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.Repository; + +@Repository +@RequiredArgsConstructor +public class EventApplicationRepositoryImpl implements EventApplicationRepository { + + private final EventApplicationJpaRepository eventApplicationJpaRepository; + private final EventApplicationAnswerJpaRepository eventApplicationAnswerJpaRepository; + + @Override + public EventApplication save(EventApplication application) { + return eventApplicationJpaRepository.save(EventApplicationJpaEntity.from(application)).toDomain(); + } + + @Override + public List saveAnswers(List answers) { + List entities = answers.stream() + .map(EventApplicationAnswerJpaEntity::from) + .toList(); + return eventApplicationAnswerJpaRepository.saveAll(entities).stream() + .map(EventApplicationAnswerJpaEntity::toDomain) + .toList(); + } + + @Override + public Map countAppliedByEventIds(List eventIds) { + if (eventIds.isEmpty()) { + return Map.of(); + } + return eventApplicationJpaRepository.countApplicantsByEventIds(eventIds, EventApplicationStatus.APPLIED) + .stream() + .collect(Collectors.toMap( + EventApplicantCountProjection::eventId, + EventApplicantCountProjection::applicantCount)); + } + + @Override + public long countAppliedByEventId(Long eventId) { + return eventApplicationJpaRepository.countByEventIdAndStatus(eventId, EventApplicationStatus.APPLIED); + } + + @Override + public long countByEventId(Long eventId) { + return eventApplicationJpaRepository.countByEventId(eventId); + } + + @Override + public boolean existsByEventId(Long eventId) { + return eventApplicationJpaRepository.existsByEventId(eventId); + } + + @Override + public boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId) { + return eventApplicationJpaRepository.existsByEventIdAndMemberIdAndStatus( + eventId, memberId, EventApplicationStatus.APPLIED + ); + } + + @Override + public List findSliceByMemberId(Long memberId, EventApplicationCursor cursor, int limit) { + return eventApplicationJpaRepository.findSliceByMemberId( + memberId, + cursor == null ? null : cursor.appliedAt(), + cursor == null ? null : cursor.applicationId(), + Pageable.ofSize(limit) + ).stream().map(EventApplicationJpaEntity::toDomain).toList(); + } + + @Override + public PageResult findPageByEventId( + Long eventId, + EventApplicationStatus status, + List memberIds, + PageOffset pageOffset + ) { + boolean filterByMember = memberIds != null; + Page result = eventApplicationJpaRepository.searchByEventId( + eventId, status, filterByMember, filterByMember ? memberIds : List.of(), + PageRequest.of(pageOffset.page(), pageOffset.size()) + ); + + return PageResult.of( + result.getContent().stream().map(EventApplicationJpaEntity::toDomain).toList(), + result.getNumber(), + result.getSize(), + result.getTotalElements() + ); + } + + @Override + public Optional findByIdAndMemberId(Long applicationId, Long memberId) { + return eventApplicationJpaRepository.findByIdAndMemberId(applicationId, memberId) + .map(EventApplicationJpaEntity::toDomain); + } + + @Override + public Optional findByIdAndEventId(Long applicationId, Long eventId) { + return eventApplicationJpaRepository.findByIdAndEventId(applicationId, eventId) + .map(EventApplicationJpaEntity::toDomain); + } + + @Override + public List findAnswersByApplicationId(Long applicationId) { + return eventApplicationAnswerJpaRepository.findAllByEventApplicationId(applicationId).stream() + .map(EventApplicationAnswerJpaEntity::toDomain) + .toList(); + } + + @Override + public List findAnswersByApplicationIds(List applicationIds) { + if (applicationIds.isEmpty()) { + return List.of(); + } + return eventApplicationAnswerJpaRepository.findAllByEventApplicationIdIn(applicationIds).stream() + .map(EventApplicationAnswerJpaEntity::toDomain) + .toList(); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java index dfc8848b..d04ced1b 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java @@ -85,15 +85,4 @@ List findPublishedSlice( @Param("cursorEventId") Long cursorEventId, Pageable pageable ); - - @Query(""" - SELECT new kr.ac.kookmin.stream.db.event.EventApplicantCountProjection(a.eventId, COUNT(a)) - FROM EventApplicationJpaEntity a - WHERE a.eventId IN :eventIds AND a.status = :appliedStatus - GROUP BY a.eventId - """) - List countApplicantsByEventIds( - @Param("eventIds") List eventIds, - @Param("appliedStatus") EventApplicationStatus appliedStatus - ); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index b90bcb3a..4f2eb5aa 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -2,15 +2,10 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; -import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; -import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; -import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; @@ -29,14 +24,22 @@ public class EventRepositoryImpl implements EventRepository { private final EventJpaRepository eventJpaRepository; private final EventQuestionJpaRepository eventQuestionJpaRepository; - private final EventApplicationJpaRepository eventApplicationJpaRepository; - private final EventApplicationAnswerJpaRepository eventApplicationAnswerJpaRepository; @Override public Event save(Event event) { return eventJpaRepository.save(EventJpaEntity.from(event)).toDomain(); } + @Override + public boolean delete(Long id) { + return eventJpaRepository.findByIdAndIsDeletedFalse(id) + .map(entity -> { + entity.delete(); + return true; + }) + .orElse(false); + } + @Override public List saveQuestions(List questions) { List entities = questions.stream() @@ -53,11 +56,6 @@ public List replaceQuestions(Long eventId, List qu return saveQuestions(questions); } - @Override - public boolean existsApplicationByEventId(Long eventId) { - return eventApplicationJpaRepository.existsByEventId(eventId); - } - @Override public void touchUpdatedAt(Long eventId) { eventJpaRepository.touchUpdatedAt(eventId); @@ -116,75 +114,10 @@ public List findPublishedSlice( ).stream().map(EventJpaEntity::toDomain).toList(); } - @Override - public Map countAppliedByEventIds(List eventIds) { - if (eventIds.isEmpty()) { - return Map.of(); - } - return eventJpaRepository.countApplicantsByEventIds(eventIds, EventApplicationStatus.APPLIED).stream() - .collect(Collectors.toMap( - EventApplicantCountProjection::eventId, - EventApplicantCountProjection::applicantCount)); - } - @Override public List findQuestionsByEventId(Long eventId) { return eventQuestionJpaRepository.findAllByEventIdOrderByDisplayOrderAsc(eventId).stream() .map(EventQuestionJpaEntity::toDomain) .toList(); } - - @Override - public long countAppliedByEventId(Long eventId) { - return eventApplicationJpaRepository.countByEventIdAndStatus(eventId, EventApplicationStatus.APPLIED); - } - - @Override - public boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId) { - return eventApplicationJpaRepository.existsByEventIdAndMemberIdAndStatus( - eventId, memberId, EventApplicationStatus.APPLIED - ); - } - - @Override - public EventApplication saveApplication(EventApplication application) { - return eventApplicationJpaRepository.save(EventApplicationJpaEntity.from(application)).toDomain(); - } - - @Override - public List findApplicationSlice( - Long memberId, - EventApplicationCursor cursor, - int limit - ) { - return eventApplicationJpaRepository.findSliceByMemberId( - memberId, - cursor == null ? null : cursor.appliedAt(), - cursor == null ? null : cursor.applicationId(), - Pageable.ofSize(limit) - ).stream().map(EventApplicationJpaEntity::toDomain).toList(); - } - - @Override - public Optional findApplicationByIdAndMemberId(Long applicationId, Long memberId) { - return eventApplicationJpaRepository.findByIdAndMemberId(applicationId, memberId) - .map(EventApplicationJpaEntity::toDomain); - } - - @Override - public List findAnswersByApplicationId(Long applicationId) { - return eventApplicationAnswerJpaRepository.findAllByEventApplicationId(applicationId).stream() - .map(EventApplicationAnswerJpaEntity::toDomain) - .toList(); - } - - @Override - public List saveAnswers(List answers) { - List entities = answers.stream() - .map(EventApplicationAnswerJpaEntity::from) - .toList(); - return eventApplicationAnswerJpaRepository.saveAll(entities).stream() - .map(EventApplicationAnswerJpaEntity::toDomain) - .toList(); - } }