diff --git a/api/admin-api/build.gradle.kts b/api/admin-api/build.gradle.kts index 983679fe..3d452814 100644 --- a/api/admin-api/build.gradle.kts +++ b/api/admin-api/build.gradle.kts @@ -7,6 +7,7 @@ description = "운영진 콘솔 — ADMIN, /v1/admin/**" dependencies { implementation(project(":api:common-api")) implementation(project(":core:common")) + implementation(project(":core:domain:event")) implementation(project(":core:domain:file")) implementation(project(":core:domain:internal")) implementation(project(":core:domain:member")) 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 new file mode 100644 index 00000000..b2368aef --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java @@ -0,0 +1,74 @@ +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.EventCreateRequest; +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.EventUpdateResponse; +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.dto.PageResponse; +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 kr.ac.kookmin.stream.file.domain.FileErrorCode; +import org.springdoc.core.annotations.ParameterObject; + +/** + * 운영진 행사 API의 문서 명세. 구현은 {@link AdminEventController}가 맡는다. + *

+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. + */ +@Tag(name = "행사 관리", description = "운영진 행사 등록·조회·수정") +public interface AdminEventApi { + + /** 행사 등록. 작성자는 토큰의 관리자로 정해진다. */ + @Operation(summary = "행사 등록", + description = "행사와 신청서 질문을 등록한다. 이미지는 업로드를 마친 파일 ID로 넘기며, 존재하지 않는 파일이 하나라도 " + + "있으면 등록하지 않는다. 새 행사는 비공개로 만들어진다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode( + type = EventErrorCode.class, + codes = {"EVENT_INVALID_PERIOD", "EVENT_INVALID_CAPACITY", "EVENT_INVALID_QUESTION"} + ) + @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"}) + ApiResponse createEvent(AdminApiUser apiUser, EventCreateRequest request); + + /** 행사 목록. 비공개 행사도 함께 내려준다. */ + @Operation(summary = "행사 목록 조회", + description = "삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순으로 페이지 단위 조회한다. keyword로 제목을 검색한다. " + + "모집 상태는 조회 시점의 신청 기간·잔여 정원으로 계산한다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + ApiResponse> getEvents( + AdminApiUser apiUser, + @ParameterObject EventSearchParams params, + @ParameterObject PageParams pageParams + ); + + /** 행사 상세. 수정 화면의 초기값으로 쓴다. */ + @Operation(summary = "행사 상세 조회", + description = "행사 정보와 신청서 질문 전체를 조회한다. 비공개 행사도 조회할 수 있다.") + @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"}) + ApiResponse getEvent(AdminApiUser apiUser, Long eventId); + + /** 행사 수정. 요청의 수정 시각이 저장된 값과 다르면 거부한다. */ + @Operation(summary = "행사 수정", + description = "행사 내용과 신청서 질문을 통째로 바꾼다. 수정 화면이 행사를 읽은 뒤 다른 관리자가 먼저 고쳤다면" + + "(수정 시각 불일치) 409로 거부한다. 신청 내역(취소 포함)이 있는 행사는 질문을 바꿀 수 없고, " + + "선착순 정원은 현재 신청자 수보다 줄일 수 없다. 공개 여부는 바꾸지 않는다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode( + type = EventErrorCode.class, + codes = { + "EVENT_NOT_FOUND", "EVENT_INVALID_PERIOD", "EVENT_INVALID_CAPACITY", "EVENT_INVALID_QUESTION", + "EVENT_QUESTIONS_LOCKED", "EVENT_CAPACITY_BELOW_APPLICANTS", "EVENT_UPDATE_CONFLICT" + } + ) + @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"}) + ApiResponse updateEvent(AdminApiUser apiUser, Long eventId, EventUpdateRequest request); +} 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 new file mode 100644 index 00000000..e6399076 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java @@ -0,0 +1,88 @@ +package kr.ac.kookmin.stream.api.admin.event.event; + +import jakarta.validation.Valid; +import java.time.LocalDateTime; +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.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.EventUpdateResponse; +import kr.ac.kookmin.stream.api.admin.event.event.usecase.AdminEventWriteUseCase; +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.dto.PageResponse; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; +import kr.ac.kookmin.stream.event.domain.event.service.EventService; +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.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/events") +@RequiredArgsConstructor +public class AdminEventController implements AdminEventApi { + + private final EventService eventService; + private final FileService fileService; + private final AdminEventWriteUseCase adminEventWriteUseCase; + + @Override + @PostMapping + public ApiResponse createEvent( + AdminApiUser apiUser, + @Valid @RequestBody EventCreateRequest request + ) { + Event event = adminEventWriteUseCase.create(apiUser.userId(), request.toCommand()); + return ApiResponse.success(EventCreateResponse.from(event)); + } + + @Override + @GetMapping + public ApiResponse> getEvents( + AdminApiUser apiUser, + @Valid @ModelAttribute EventSearchParams params, + @Valid @ModelAttribute PageParams pageParams + ) { + PageResult events = eventService.getEvents(params.toKeyword(), pageParams.toOffset()); + Map applicantCounts = eventService.getApplicantCounts( + events.content().stream().map(Event::getId).toList()); + LocalDateTime now = LocalDateTime.now(); + + return ApiResponse.success(PageResponse.from(events, event -> + AdminEventListItemResponse.of(event, applicantCounts.getOrDefault(event.getId(), 0L), now))); + } + + @Override + @GetMapping("/{eventId}") + public ApiResponse getEvent(AdminApiUser apiUser, @PathVariable Long eventId) { + EventManageDetail detail = eventService.getEvent(eventId); + Map imagesById = fileService.findAllByIdIn(detail.imageIds()); + + return ApiResponse.success(AdminEventDetailResponse.of(detail, imagesById, LocalDateTime.now())); + } + + @Override + @PutMapping("/{eventId}") + public ApiResponse updateEvent( + AdminApiUser apiUser, + @PathVariable Long eventId, + @Valid @RequestBody EventUpdateRequest request + ) { + Event event = adminEventWriteUseCase.update(eventId, request.toCommand()); + return ApiResponse.success(EventUpdateResponse.from(event)); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java new file mode 100644 index 00000000..d8509ffc --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java @@ -0,0 +1,86 @@ +package kr.ac.kookmin.stream.api.admin.event.event.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; + +/** + * 작성자는 요청 본문이 아니라 토큰의 관리자 ID로 정한다. 공개 여부는 받지 않고 비공개로 시작한다. + */ +public record EventCreateRequest( + @Schema(description = "행사 제목", example = "체육대회") + @NotBlank(message = "행사 제목을 입력해 주세요.") + @Size(max = 200, message = "행사 제목은 200자 이하로 입력해 주세요.") + String title, + + // description은 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다 + @Schema(description = "행사 소개글", example = "학과 대항 체육대회입니다.") + @NotBlank(message = "행사 소개글을 입력해 주세요.") + @Size(max = 16000, message = "행사 소개글은 16000자 이하로 입력해 주세요.") + String description, + + @Schema(description = "행사 장소", example = "종합운동장") + @Size(max = 255, message = "행사 장소는 255자 이하로 입력해 주세요.") + String place, + + @Schema(description = "행사 시작 일시", example = "2026-11-01T10:00:00") + @NotNull(message = "행사 시작 일시를 입력해 주세요.") + LocalDateTime eventStartAt, + + @Schema(description = "행사 종료 일시. 시작 일시보다 늦어야 한다", example = "2026-11-01T18:00:00") + @NotNull(message = "행사 종료 일시를 입력해 주세요.") + LocalDateTime eventEndAt, + + @Schema(description = "신청 시작 일시", example = "2026-10-14T10:00:00") + @NotNull(message = "신청 시작 일시를 입력해 주세요.") + LocalDateTime applyStartAt, + + @Schema(description = "신청 마감 일시. 시작 일시보다 늦어야 한다", example = "2026-10-25T23:59:59") + @NotNull(message = "신청 마감 일시를 입력해 주세요.") + LocalDateTime applyEndAt, + + @Schema(description = "모집 방식. 선착순이면 FIRST_COME, 인원 제한이 없으면 OPEN") + @NotNull(message = "모집 방식을 입력해 주세요.") + RecruitType recruitType, + + @Schema(description = "모집 정원. FIRST_COME이면 1 이상 필요하고, OPEN이면 무시한다", example = "100") + Integer capacity, + + @Schema(description = "학생회비 납부자만 신청 가능 여부") + @NotNull(message = "학생회비 납부자 전용 여부를 입력해 주세요.") + Boolean feePaidOnly, + + @Schema(description = "행사 이미지 파일 ID 목록. 첫 번째가 대표 이미지가 된다", example = "[31, 32]") + @NotNull(message = "이미지 파일 목록을 입력해 주세요.") + @Size(max = 10, message = "이미지는 10장까지 등록할 수 있습니다.") + List<@NotNull(message = "이미지 파일 ID는 비워 둘 수 없습니다.") Long> imageIds, + + @Schema(description = "신청서 질문 목록. 목록 순서대로 신청서에 노출된다") + @NotNull(message = "신청서 질문 목록을 입력해 주세요.") + @Valid + List<@NotNull(message = "신청서 질문이 비어 있습니다.") EventQuestionRequest> questions +) { + + public EventCreateCommand toCommand() { + return new EventCreateCommand( + title, + description, + place, + eventStartAt, + eventEndAt, + applyStartAt, + applyEndAt, + recruitType, + capacity, + feePaidOnly, + imageIds.stream().distinct().toList(), + questions.stream().map(EventQuestionRequest::toCommand).toList() + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventQuestionRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventQuestionRequest.java new file mode 100644 index 00000000..a507a69a --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventQuestionRequest.java @@ -0,0 +1,44 @@ +package kr.ac.kookmin.stream.api.admin.event.event.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType; + +/** + * 신청서 질문 한 건. 행사 등록·수정 요청이 함께 쓴다. 노출 순서는 목록에서의 위치로 정하므로 따로 받지 않는다. + */ +public record EventQuestionRequest( + @Schema(description = "질문 유형") + @NotNull(message = "질문 유형을 입력해 주세요.") + QuestionType questionType, + + @Schema(description = "질문 내용", example = "참여 종목") + @NotBlank(message = "질문 내용을 입력해 주세요.") + @Size(max = 500, message = "질문 내용은 500자 이하로 입력해 주세요.") + String questionText, + + @Schema(description = "필수 응답 여부") + @NotNull(message = "필수 응답 여부를 입력해 주세요.") + Boolean isRequired, + + @Schema(description = "선택지 목록. 선택형이면 1개 이상 필요하고, 텍스트형이면 무시한다", example = "[\"축구\", \"농구\"]") + List<@NotBlank(message = "선택지는 비워 둘 수 없습니다.") String> options, + + @Schema(description = "'기타'(직접 입력) 선택지 추가 여부. 선택형에서만 쓰며 기본값은 false") + Boolean hasOtherOption +) { + + public EventQuestionCommand toCommand() { + return new EventQuestionCommand( + questionText, + questionType, + isRequired, + options == null ? List.of() : options, + Boolean.TRUE.equals(hasOtherOption) + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java new file mode 100644 index 00000000..86cfeb9d --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java @@ -0,0 +1,14 @@ +package kr.ac.kookmin.stream.api.admin.event.event.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record EventSearchParams( + @Schema(description = "행사 제목 검색어", example = "체육대회") + String keyword +) { + + // 빈 검색어는 검색하지 않는 것으로 본다 + public String toKeyword() { + return keyword == null || keyword.isBlank() ? null : keyword.strip(); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java new file mode 100644 index 00000000..8a0722a9 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java @@ -0,0 +1,94 @@ +package kr.ac.kookmin.stream.api.admin.event.event.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.common.DateUtil; +import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; + +/** + * 공개 여부는 받지 않는다. 공개 전환은 별도 API가 맡는다. + */ +public record EventUpdateRequest( + @Schema(description = "행사 제목", example = "체육대회") + @NotBlank(message = "행사 제목을 입력해 주세요.") + @Size(max = 200, message = "행사 제목은 200자 이하로 입력해 주세요.") + String title, + + // description은 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다 + @Schema(description = "행사 소개글", example = "학과 대항 체육대회입니다.") + @NotBlank(message = "행사 소개글을 입력해 주세요.") + @Size(max = 16000, message = "행사 소개글은 16000자 이하로 입력해 주세요.") + String description, + + @Schema(description = "행사 장소", example = "종합운동장") + @Size(max = 255, message = "행사 장소는 255자 이하로 입력해 주세요.") + String place, + + @Schema(description = "행사 시작 일시", example = "2026-11-01T10:00:00") + @NotNull(message = "행사 시작 일시를 입력해 주세요.") + LocalDateTime eventStartAt, + + @Schema(description = "행사 종료 일시. 시작 일시보다 늦어야 한다", example = "2026-11-01T18:00:00") + @NotNull(message = "행사 종료 일시를 입력해 주세요.") + LocalDateTime eventEndAt, + + @Schema(description = "신청 시작 일시", example = "2026-10-14T10:00:00") + @NotNull(message = "신청 시작 일시를 입력해 주세요.") + LocalDateTime applyStartAt, + + @Schema(description = "신청 마감 일시. 시작 일시보다 늦어야 한다", example = "2026-10-25T23:59:59") + @NotNull(message = "신청 마감 일시를 입력해 주세요.") + LocalDateTime applyEndAt, + + @Schema(description = "모집 방식. 선착순이면 FIRST_COME, 인원 제한이 없으면 OPEN") + @NotNull(message = "모집 방식을 입력해 주세요.") + RecruitType recruitType, + + @Schema(description = "모집 정원. FIRST_COME이면 1 이상 필요하고, OPEN이면 무시한다", example = "100") + Integer capacity, + + @Schema(description = "학생회비 납부자만 신청 가능 여부") + @NotNull(message = "학생회비 납부자 전용 여부를 입력해 주세요.") + Boolean feePaidOnly, + + @Schema(description = "행사 이미지 파일 ID 목록. 첫 번째가 대표 이미지가 된다", example = "[31, 32]") + @NotNull(message = "이미지 파일 목록을 입력해 주세요.") + @Size(max = 10, message = "이미지는 10장까지 등록할 수 있습니다.") + List<@NotNull(message = "이미지 파일 ID는 비워 둘 수 없습니다.") Long> imageIds, + + @Schema(description = "신청서 질문 목록. 목록 순서대로 신청서에 노출된다") + @NotNull(message = "신청서 질문 목록을 입력해 주세요.") + @Valid + List<@NotNull(message = "신청서 질문이 비어 있습니다.") EventQuestionRequest> questions, + + @Schema(description = "수정 화면이 행사를 읽었을 때의 수정 시각(KST 기준 epoch 밀리초). 그 사이 다른 관리자가 고쳤다면 409", + example = "1756339200000") + @NotNull(message = "수정 시각을 입력해 주세요.") + Long updatedAt +) { + + public EventUpdateCommand toCommand() { + return new EventUpdateCommand( + title, + description, + place, + eventStartAt, + eventEndAt, + applyStartAt, + applyEndAt, + recruitType, + capacity, + feePaidOnly, + imageIds.stream().distinct().toList(), + questions.stream().map(EventQuestionRequest::toCommand).toList(), + LocalDateTime.ofInstant(Instant.ofEpochMilli(updatedAt), DateUtil.KST) + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java new file mode 100644 index 00000000..7b9fc9a1 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java @@ -0,0 +1,101 @@ +package kr.ac.kookmin.stream.api.admin.event.event.response; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; +import kr.ac.kookmin.stream.common.DateUtil; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +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.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import kr.ac.kookmin.stream.file.domain.File; + +/** + * 수정 화면의 초기값. 필드 이름은 등록·수정 요청 본문과 맞춘다. + * + * @param capacity 선착순 모집의 정원. 상시 모집이면 {@code null} + * @param updatedAt 마지막 수정 시각(KST 기준 epoch 밀리초). 수정 요청에 그대로 돌려보내 동시 수정을 감지한다 + */ +public record AdminEventDetailResponse( + Long eventId, + String title, + String description, + String place, + LocalDateTime eventStartAt, + LocalDateTime eventEndAt, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitType recruitType, + Integer capacity, + boolean feePaidOnly, + long applicantCount, + RecruitStatus recruitStatus, + boolean isPublished, + List images, + List questions, + long updatedAt +) { + + /** + * 존재하지 않는(삭제된) fileId는 imagesById에 키가 없다 — 그런 항목은 목록에서 조용히 제외한다. + * 모집 상태는 저장값이 아니라 {@code now} 기준으로 행사가 계산한다. + */ + public static AdminEventDetailResponse of(EventManageDetail detail, Map imagesById, LocalDateTime now) { + Event event = detail.event(); + return new AdminEventDetailResponse( + event.getId(), + event.getTitle(), + event.getDescription(), + event.getPlace(), + event.getEventStartAt(), + event.getEventEndAt(), + event.getApplyStartAt(), + event.getApplyEndAt(), + event.getRecruitType(), + event.capacityLimit(), + event.isFeePaidOnly(), + detail.applicantCount(), + event.calculateRecruitStatus(now, detail.applicantCount()), + event.isPublished(), + detail.imageIds().stream() + .filter(imagesById::containsKey) + .map(fileId -> Image.of(fileId, imagesById.get(fileId))) + .toList(), + detail.questions().stream().map(Question::from).toList(), + event.getUpdatedAt().atZone(DateUtil.KST).toInstant().toEpochMilli() + ); + } + + public record Image(Long fileId, String fileUrl) { + + public static Image of(Long fileId, File file) { + return new Image(fileId, StorageUrlBuilder.build(file.getFileKey())); + } + } + + public record Question( + Long questionId, + QuestionType questionType, + String questionText, + boolean isRequired, + int displayOrder, + List options, + boolean hasOtherOption + ) { + + public static Question from(EventQuestion question) { + return new Question( + question.getId(), + question.getQuestionType(), + question.getQuestionText(), + question.isRequired(), + question.getDisplayOrder(), + question.getOptions() == null ? List.of() : question.getOptions(), + question.isOtherOptionAllowed() + ); + } + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventListItemResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventListItemResponse.java new file mode 100644 index 00000000..b1ce3b47 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventListItemResponse.java @@ -0,0 +1,39 @@ +package kr.ac.kookmin.stream.api.admin.event.event.response; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; + +/** + * @param capacity 선착순 모집의 정원. 상시 모집이면 {@code null}이고 화면에 "인원 제한 없음"으로 보인다 + */ +public record AdminEventListItemResponse( + Long eventId, + String title, + long applicantCount, + RecruitType recruitType, + Integer capacity, + RecruitStatus recruitStatus, + boolean isPublished, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt +) { + + /** + * 모집 상태는 저장값이 아니라 {@code now} 기준으로 행사가 계산한다. 목록 전체가 같은 시각을 봐야 하므로 호출부가 넘긴다. + */ + public static AdminEventListItemResponse of(Event event, long applicantCount, LocalDateTime now) { + return new AdminEventListItemResponse( + event.getId(), + event.getTitle(), + applicantCount, + event.getRecruitType(), + event.capacityLimit(), + event.calculateRecruitStatus(now, applicantCount), + event.isPublished(), + event.getApplyStartAt(), + event.getApplyEndAt() + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventCreateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventCreateResponse.java new file mode 100644 index 00000000..2feb1408 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventCreateResponse.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 EventCreateResponse(Long eventId) { + + public static EventCreateResponse from(Event event) { + return new EventCreateResponse(event.getId()); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventUpdateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventUpdateResponse.java new file mode 100644 index 00000000..93cc991d --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventUpdateResponse.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 EventUpdateResponse(Long eventId) { + + public static EventUpdateResponse from(Event event) { + return new EventUpdateResponse(event.getId()); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java new file mode 100644 index 00000000..0ca98572 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java @@ -0,0 +1,44 @@ +package kr.ac.kookmin.stream.api.admin.event.event.usecase; + +import java.util.List; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; +import kr.ac.kookmin.stream.event.domain.event.service.EventService; +import kr.ac.kookmin.stream.file.domain.FileErrorCode; +import kr.ac.kookmin.stream.file.service.FileService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 행사를 쓰기 전에 연결할 이미지 파일이 실제로 있는지 확인한다. 행사(event)와 파일(file)은 서로 다른 도메인이라 + * 도메인끼리 직접 의존하지 않고 이 UseCase가 두 Service를 조합한다. + *

+ * 파일 확인과 행사 저장은 한 트랜잭션으로 묶지 않는다. 확인 직후 파일이 지워져도 행사 조회가 삭제된 파일을 + * 목록에서 조용히 제외하므로 원자성이 필요하지 않다. + */ +@Component +@RequiredArgsConstructor +public class AdminEventWriteUseCase { + + private final EventService eventService; + private final FileService fileService; + + public Event create(Long createdBy, EventCreateCommand command) { + requireImagesExist(command.imageIds()); + return eventService.create(createdBy, command); + } + + public Event update(Long eventId, EventUpdateCommand command) { + requireImagesExist(command.imageIds()); + return eventService.update(eventId, command); + } + + // 요청 DTO가 중복을 걸러 넘기므로 조회 결과 개수와 바로 비교할 수 있다 + private void requireImagesExist(List imageIds) { + if (fileService.findAllByIdIn(imageIds).size() != imageIds.size()) { + throw new BusinessException(FileErrorCode.FILE_NOT_FOUND); + } + } +} 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 e548e6f9..6ab5f3e1 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 @@ -24,8 +24,12 @@ public class Event { private RecruitType recruitType; private List imageIds; private int capacity; + /** 학생회비 납부자만 신청할 수 있는지. 신청 시 검증은 아직 없다(후속 작업) */ + private boolean feePaidOnly; private boolean published; private Long createdBy; + /** 마지막 수정 시각. DB가 채우며, 운영진 동시 수정 감지에 쓴다 */ + private LocalDateTime updatedAt; public static Event of( Long id, @@ -39,15 +43,58 @@ public static Event of( RecruitType recruitType, List imageIds, int capacity, + boolean feePaidOnly, boolean published, - Long createdBy + Long createdBy, + LocalDateTime updatedAt ) { return new Event( id, title, description, place, eventStartAt, eventEndAt, applyStartAt, - applyEndAt, recruitType, imageIds, capacity, published, createdBy + applyEndAt, recruitType, imageIds, capacity, feePaidOnly, published, createdBy, updatedAt ); } + /** + * 새 행사를 만든다. 학생에게 바로 보이지 않도록 비공개로 시작한다. 상시 모집은 정원이 의미 없어 0으로 둔다. + * 수정 시각은 DB가 채운다. + */ + public static Event create(Long createdBy, EventCreateCommand command) { + return new Event( + null, + command.title(), + command.description(), + command.place(), + command.eventStartAt(), + command.eventEndAt(), + command.applyStartAt(), + command.applyEndAt(), + command.recruitType(), + command.imageIds(), + capacityOf(command.recruitType(), command.capacity()), + command.feePaidOnly(), + false, + createdBy, + null + ); + } + + /** + * 운영진이 입력한 값으로 행사 내용을 통째로 바꾼다. 공개 여부·작성자는 그대로 둔다. + */ + public void update(EventUpdateCommand command) { + this.title = command.title(); + this.description = command.description(); + this.place = command.place(); + this.eventStartAt = command.eventStartAt(); + this.eventEndAt = command.eventEndAt(); + this.applyStartAt = command.applyStartAt(); + this.applyEndAt = command.applyEndAt(); + this.recruitType = command.recruitType(); + this.imageIds = command.imageIds(); + this.capacity = capacityOf(command.recruitType(), command.capacity()); + this.feePaidOnly = command.feePaidOnly(); + } + /** * 현재 시각과 신청자 수를 반영한 모집 상태를 계산한다. *

@@ -92,6 +139,13 @@ public Long thumbnailFileId() { return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst(); } + /** + * 운영진 화면에 보여줄 정원. 상시 모집은 인원 제한이 없어 {@code null}이다(저장값 0은 의미가 없다). + */ + public Integer capacityLimit() { + return recruitType == RecruitType.FIRST_COME ? capacity : null; + } + /** * 정원이 찼는지 판정한다. 선착순 모집에만 정원 제한이 있고, 상시 모집은 인원 제한이 없다. */ @@ -109,6 +163,10 @@ public boolean isClosedByCapacityOnly(LocalDateTime now, long appliedCount) { && isCapacityFull(appliedCount); } + private static int capacityOf(RecruitType recruitType, Integer capacity) { + return recruitType == RecruitType.FIRST_COME ? capacity : 0; + } + private boolean isBeforeApplyPeriod(LocalDateTime now) { return now.isBefore(applyStartAt); } 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 new file mode 100644 index 00000000..2d63c3c1 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java @@ -0,0 +1,23 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * @param capacity 선착순 모집의 정원. 상시 모집이면 쓰이지 않는다 + */ +public record EventCreateCommand( + String title, + String description, + String place, + LocalDateTime eventStartAt, + LocalDateTime eventEndAt, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitType recruitType, + Integer capacity, + boolean feePaidOnly, + List imageIds, + List questions +) { +} 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 65f1b728..f978529e 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 @@ -20,7 +20,13 @@ public enum EventErrorCode implements ErrorCode { CANCEL_DEADLINE_PASSED(ErrorStatus.CONFLICT, "신청이 마감되어 취소할 수 없습니다."), INVALID_ANSWER(ErrorStatus.BAD_REQUEST, "신청서 답변 형식이 올바르지 않습니다."), EVENT_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."), - EVENT_INVALID_RECRUIT_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 모집 상태입니다."); + EVENT_INVALID_RECRUIT_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 모집 상태입니다."), + EVENT_INVALID_PERIOD(ErrorStatus.BAD_REQUEST, "종료 일시는 시작 일시보다 늦어야 합니다."), + EVENT_INVALID_CAPACITY(ErrorStatus.BAD_REQUEST, "선착순 모집은 1명 이상의 정원을 입력해야 합니다."), + EVENT_INVALID_QUESTION(ErrorStatus.BAD_REQUEST, "선택형 질문에는 선택지를 1개 이상 입력해야 합니다."), + EVENT_QUESTIONS_LOCKED(ErrorStatus.CONFLICT, "신청 내역이 있는 행사는 신청서 질문을 수정할 수 없습니다."), + EVENT_CAPACITY_BELOW_APPLICANTS(ErrorStatus.CONFLICT, "정원을 현재 신청자 수보다 적게 줄일 수 없습니다."), + EVENT_UPDATE_CONFLICT(ErrorStatus.CONFLICT, "다른 관리자가 먼저 행사를 수정했습니다. 새로고침한 뒤 다시 수정해 주세요."); private final int status; private final String message; diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java new file mode 100644 index 00000000..164c5619 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.util.List; + +/** + * 운영진 행사 상세에 필요한 도메인 객체 묶음. 화면에 맞춘 값(모집 상태 등)은 담지 않고, + * 응답을 만드는 쪽이 행사의 계산 메서드로 꺼낸다. + * + * @param questions 노출 순서대로 정렬된 신청서 질문 + * @param applicantCount 유효(APPLIED) 신청자 수 + */ +public record EventManageDetail( + Event event, + List questions, + long applicantCount +) { + + /** 행사 이미지 id. 이미지가 없으면 {@code null}이 아니라 빈 목록이다. */ + public List imageIds() { + return event.getImageIds() == null ? List.of() : event.getImageIds(); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java index 1efc0f04..34dcbd72 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.event.domain.event.domain; import java.util.List; +import java.util.Objects; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -18,6 +19,8 @@ public class EventQuestion { private boolean required; private int displayOrder; private List options; + /** 선택형 질문에 '기타'(직접 입력) 선택지를 덧붙이는지. 학생 앱 노출·답변 저장은 아직 없다(후속 작업) */ + private boolean otherOptionAllowed; public static EventQuestion of( Long id, @@ -26,8 +29,42 @@ public static EventQuestion of( QuestionType questionType, boolean required, int displayOrder, - List options + List options, + boolean otherOptionAllowed ) { - return new EventQuestion(id, eventId, questionText, questionType, required, displayOrder, options); + return new EventQuestion( + id, eventId, questionText, questionType, required, displayOrder, options, otherOptionAllowed + ); + } + + /** + * 새 질문을 만든다. 텍스트형 질문에 딸려 온 선택지·'기타' 여부는 의미가 없으므로 버린다. + * + * @param displayOrder 신청서에서 몇 번째로 보일지. 1부터 센다 + */ + public static EventQuestion create(Long eventId, int displayOrder, EventQuestionCommand command) { + boolean choice = command.questionType().isChoice(); + return new EventQuestion( + null, + eventId, + command.questionText(), + command.questionType(), + command.required(), + displayOrder, + choice ? List.copyOf(command.options()) : List.of(), + choice && command.otherOptionAllowed() + ); + } + + /** + * 신청서에 보이는 내용이 같은지. 식별자·소속 행사·노출 순서 값은 보지 않는다(순서는 호출부가 목록 위치로 비교한다). + * 행사 수정 때 질문이 실제로 바뀌었는지 판단한다. + */ + public boolean hasSameContentAs(EventQuestion other) { + return questionType == other.questionType + && required == other.required + && otherOptionAllowed == other.otherOptionAllowed + && Objects.equals(questionText, other.questionText) + && Objects.equals(options, other.options); } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionCommand.java new file mode 100644 index 00000000..e5de6e19 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionCommand.java @@ -0,0 +1,15 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.util.List; + +/** + * 행사 등록·수정 요청에 담긴 신청서 질문 한 건. 노출 순서는 목록에서의 위치로 정한다. + */ +public record EventQuestionCommand( + String questionText, + QuestionType questionType, + boolean required, + List options, + boolean otherOptionAllowed +) { +} 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 new file mode 100644 index 00000000..31ea49c9 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java @@ -0,0 +1,25 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * @param capacity 선착순 모집의 정원. 상시 모집이면 쓰이지 않는다 + * @param expectedUpdatedAt 수정 화면이 행사를 읽었을 때의 수정 시각. 그 사이 다른 운영진이 고쳤는지 확인한다 + */ +public record EventUpdateCommand( + String title, + String description, + String place, + LocalDateTime eventStartAt, + LocalDateTime eventEndAt, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitType recruitType, + Integer capacity, + boolean feePaidOnly, + List imageIds, + List questions, + LocalDateTime expectedUpdatedAt +) { +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/QuestionType.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/QuestionType.java index a5990530..4f936b35 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/QuestionType.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/QuestionType.java @@ -17,4 +17,9 @@ public enum QuestionType { /** 답변 길이 제한. 선택형 질문은 답변이 텍스트가 아니라 null이다. */ private final Integer maxLength; + + /** 선택지에서 고르는 질문인지. 선택지·'기타' 선택지는 이 유형에만 의미가 있다. */ + public boolean isChoice() { + return this == SINGLE_CHOICE || this == MULTIPLE_CHOICE; + } } 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 bd109be5..fb496043 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 @@ -4,6 +4,8 @@ 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; @@ -16,6 +18,31 @@ public interface EventRepository { Optional findById(Long id); + Event save(Event event); + + List saveQuestions(List questions); + + /** 행사의 신청서 질문을 모두 지우고 주어진 질문으로 바꾼다. */ + List replaceQuestions(Long eventId, List questions); + + /** + * 행사에 신청이 한 건이라도 있는지. 취소된 신청도 센다 — 취소돼도 답변이 질문 id·선택지 순서를 참조한다. + */ + boolean existsApplicationByEventId(Long eventId); + + /** + * 행사의 수정 시각을 지금으로 갱신한다. 신청서 질문만 바뀌면 events 행의 값이 그대로라 + * DB가 updated_at을 올리지 않으므로, 동시 수정 감지를 위해 직접 올린다. + */ + void touchUpdatedAt(Long eventId); + + /** + * 삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순(eventId 내림차순)으로 페이지 조회한다. 운영진 목록용이다. + * + * @param keyword 제목 부분 일치 검색어. {@code null}이면 거르지 않는다 + */ + PageResult findPage(String keyword, PageOffset pageOffset); + /** * 주어진 식별자의 행사를 조회한다. 삭제 여부로 거르지 않는다. *

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 c032dee9..b6dfc05e 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 @@ -1,15 +1,50 @@ 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.Event; +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.EventDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; +import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; -/** 행사 자체의 조회. 신청·취소는 {@link EventApplicationService}가 맡는다. */ +/** 행사 자체의 조회와 운영진의 등록·수정. 신청·취소는 {@link EventApplicationService}가 맡는다. */ public interface EventService { CursorSliceResult getPublishedEvents(RecruitStatus recruitStatus, EventCursor cursor, int size); EventDetail getPublishedEvent(Long eventId); + + /** + * 운영진이 행사를 등록한다. 신청서 질문도 함께 저장하며, 등록한 행사는 비공개로 시작한다. + */ + Event create(Long createdBy, EventCreateCommand command); + + /** + * 운영진 행사 목록. 비공개 행사도 담는다. 신청자 수는 {@link #getApplicantCounts}로 따로 조회해 조합한다. + * + * @param keyword 제목 부분 일치 검색어. {@code null}이면 거르지 않는다 + */ + PageResult getEvents(String keyword, PageOffset pageOffset); + + /** + * 행사별 유효(APPLIED) 신청자 수. 신청이 한 건도 없는 행사는 결과에 담기지 않는다. + */ + Map getApplicantCounts(List eventIds); + + /** 운영진 행사 상세. 비공개 행사도 조회한다. */ + EventManageDetail getEvent(Long eventId); + + /** + * 운영진이 행사 내용을 통째로 바꾼다. 수정 화면이 읽은 뒤 다른 운영진이 먼저 고쳤다면 + * {@code EVENT_UPDATE_CONFLICT}로 거부한다. 신청이 있는 행사는 신청서 질문을 바꿀 수 없고, + * 선착순 정원을 현재 신청자 수보다 줄일 수 없다. + */ + Event update(Long eventId, EventUpdateCommand command); } 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 cc42849a..6842679e 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 @@ -1,16 +1,26 @@ package kr.ac.kookmin.stream.event.domain.event.service.impl; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; 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.EventCreateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; 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; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; +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.EventRepository; import kr.ac.kookmin.stream.event.domain.event.service.EventService; import lombok.RequiredArgsConstructor; @@ -22,6 +32,7 @@ class EventServiceImpl implements EventService { private final EventRepository eventRepository; + private final EventWriteValidator eventWriteValidator; @Override @Transactional(readOnly = true) @@ -56,4 +67,95 @@ public EventDetail getPublishedEvent(Long eventId) { LocalDateTime now = LocalDateTime.now(); return EventDetail.of(event, eventRepository.countAppliedByEventId(eventId), now); } + + @Override + @Transactional + public Event create(Long createdBy, EventCreateCommand command) { + eventWriteValidator.validate(command); + + Event event = eventRepository.save(Event.create(createdBy, command)); + eventRepository.saveQuestions(toQuestions(event.getId(), command.questions())); + return event; + } + + @Override + @Transactional(readOnly = true) + public PageResult getEvents(String keyword, PageOffset pageOffset) { + return eventRepository.findPage(keyword, pageOffset); + } + + @Override + @Transactional(readOnly = true) + public Map getApplicantCounts(List eventIds) { + return eventRepository.countAppliedByEventIds(eventIds); + } + + @Override + @Transactional(readOnly = true) + public EventManageDetail getEvent(Long eventId) { + Event event = getEventOrThrow(eventId); + return new EventManageDetail( + event, + eventRepository.findQuestionsByEventId(eventId), + eventRepository.countAppliedByEventId(eventId) + ); + } + + @Override + @Transactional + public Event update(Long eventId, EventUpdateCommand command) { + Event event = getEventOrThrow(eventId); + validateNotModified(event, command.expectedUpdatedAt()); + eventWriteValidator.validate(command); + validateCapacityNotBelowApplicants(eventId, command); + + List questions = toQuestions(eventId, command.questions()); + if (questionsChanged(eventRepository.findQuestionsByEventId(eventId), questions)) { + // 답변이 질문 id·선택지 순서로 저장돼 있어 신청이 생긴 뒤 바꾸면 기존 답변의 뜻이 달라진다 + if (eventRepository.existsApplicationByEventId(eventId)) { + throw new BusinessException(EventErrorCode.EVENT_QUESTIONS_LOCKED); + } + eventRepository.replaceQuestions(eventId, questions); + eventRepository.touchUpdatedAt(eventId); + } + + event.update(command); + return eventRepository.save(event); + } + + private Event getEventOrThrow(Long eventId) { + return eventRepository.findById(eventId) + .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); + } + + // updated_at이 초 단위 DATETIME이라 같은 초로 맞춰 비교한다. 수정 화면이 읽은 뒤 고쳐졌다면 시각이 달라진다 + private void validateNotModified(Event event, LocalDateTime expectedUpdatedAt) { + LocalDateTime current = event.getUpdatedAt().truncatedTo(ChronoUnit.SECONDS); + if (!current.equals(expectedUpdatedAt.truncatedTo(ChronoUnit.SECONDS))) { + throw new BusinessException(EventErrorCode.EVENT_UPDATE_CONFLICT); + } + } + + // 이미 받은 신청을 정원 밖으로 밀어낼 수는 없다. 상시 모집은 인원 제한이 없어 보지 않는다 + private void validateCapacityNotBelowApplicants(Long eventId, EventUpdateCommand command) { + if (command.recruitType() == RecruitType.FIRST_COME + && command.capacity() < eventRepository.countAppliedByEventId(eventId)) { + throw new BusinessException(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS); + } + } + + private static boolean questionsChanged(List current, List requested) { + if (current.size() != requested.size()) { + return true; + } + return IntStream.range(0, current.size()) + .anyMatch(index -> !current.get(index).hasSameContentAs(requested.get(index))); + } + + // 신청서에 보이는 순서는 요청 목록의 순서를 따른다 + private static List toQuestions(Long eventId, List commands) { + return IntStream.range(0, commands.size()) + .mapToObj(index -> EventQuestion.create(eventId, index + 1, commands.get(index))) + .toList(); + } } 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 new file mode 100644 index 00000000..938c5547 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java @@ -0,0 +1,55 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +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.RecruitType; +import org.springframework.stereotype.Component; + +/** + * 운영진이 행사를 등록·수정할 때 요청 형식(Bean Validation)만으로는 걸러지지 않는 규칙을 확인한다. + * 필드 하나로 판단할 수 없는 조합(시작·종료 순서, 모집 방식에 따른 정원, 질문 유형에 따른 선택지)을 본다. + */ +@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) { + validatePeriod(command.eventStartAt(), command.eventEndAt()); + validatePeriod(command.applyStartAt(), command.applyEndAt()); + validateCapacity(command.recruitType(), command.capacity()); + validateQuestions(command.questions()); + } + + private void validatePeriod(LocalDateTime startAt, LocalDateTime endAt) { + if (!endAt.isAfter(startAt)) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_PERIOD); + } + } + + // 상시 모집은 인원 제한이 없어 정원을 보지 않는다 + private void validateCapacity(RecruitType recruitType, Integer capacity) { + if (recruitType == RecruitType.FIRST_COME && (capacity == null || capacity < 1)) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_CAPACITY); + } + } + + private void validateQuestions(List questions) { + boolean choiceWithoutOptions = questions.stream() + .anyMatch(question -> question.questionType().isChoice() + && (question.options() == null || question.options().isEmpty())); + if (choiceWithoutOptions) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_QUESTION); + } + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java index e013b98f..3967763c 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java @@ -29,7 +29,7 @@ void fromSummary() { Event event = Event.of( 15L, "행사", "설명", "학생회관", APPLIED_AT.plusDays(30), APPLIED_AT.plusDays(31), APPLIED_AT.minusDays(7), APPLIED_AT.plusDays(3), - RecruitType.OPEN, List.of(), 0, true, 100L + RecruitType.OPEN, List.of(), 0, false, true, 100L, null ); EventApplication application = EventApplication.of( 102L, 15L, 1L, EventApplicationStatus.APPLIED, APPLIED_AT, null); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java index cf667f61..c2c356f8 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java @@ -15,15 +15,15 @@ class EventApplicationDetailTest { private static final EventQuestion CHOICE_QUESTION = EventQuestion.of( 1L, 15L, "관심 분야를 선택해 주세요.", QuestionType.MULTIPLE_CHOICE, true, 0, - List.of("텍스트1", "텍스트2", "텍스트3")); + List.of("텍스트1", "텍스트2", "텍스트3"), false); private static final EventQuestion TEXT_QUESTION = EventQuestion.of( - 2L, 15L, "궁금한 점을 남겨 주세요.", QuestionType.LONG_TEXT, false, 1, List.of()); + 2L, 15L, "궁금한 점을 남겨 주세요.", QuestionType.LONG_TEXT, false, 1, List.of(), false); private static Event event() { return Event.of( 15L, "동문패널톡", "설명", "미래관 419호", APPLIED_AT.plusDays(30), APPLIED_AT.plusDays(31), APPLIED_AT.minusDays(7), APPLIED_AT.plusDays(3), - RecruitType.OPEN, List.of(7L), 0, true, 100L + RecruitType.OPEN, List.of(7L), 0, false, true, 100L, null ); } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java index 3ca8e342..8aed3345 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java @@ -29,7 +29,7 @@ void fromEvent() { Event event = Event.of( 42L, "행사", "설명", "학생회관", EVENT_START_AT, EVENT_START_AT.plusHours(2), EVENT_START_AT.minusDays(7), EVENT_START_AT.minusDays(1), - RecruitType.OPEN, List.of(), 0, true, 100L + RecruitType.OPEN, List.of(), 0, false, true, 100L, null ); assertEquals(new EventCursor(EVENT_START_AT, 42L), EventCursor.of(event)); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java index 4afdc859..ae397121 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java @@ -18,7 +18,7 @@ private static Event event(RecruitType recruitType, List imageIds) { return Event.of( 15L, "2026 소융대 개강 행사", "소융대 재학생을 위한 개강 행사입니다.", "101호", NOW.plusDays(10), NOW.plusDays(10).plusHours(2), NOW.minusDays(1), NOW.plusDays(3), - recruitType, imageIds, 30, true, 1L + recruitType, imageIds, 30, false, true, 1L, null ); } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java new file mode 100644 index 00000000..2b0a1cd2 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java @@ -0,0 +1,122 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class EventQuestionTest { + + private static EventQuestionCommand command(QuestionType type, List options, boolean otherOptionAllowed) { + return new EventQuestionCommand("참여 종목", type, true, options, otherOptionAllowed); + } + + private static EventQuestion saved(Long id, QuestionType type, List options, boolean otherOptionAllowed) { + return EventQuestion.of(id, 15L, "참여 종목", type, true, 1, options, otherOptionAllowed); + } + + @Nested + @DisplayName("새 질문 만들기") + class Create { + + @Test + @DisplayName("소속 행사와 노출 순서를 붙여 입력값을 담는다") + void copiesCommand() { + EventQuestion question = EventQuestion.create( + 15L, 2, command(QuestionType.MULTIPLE_CHOICE, List.of("축구", "농구"), true)); + + assertNull(question.getId()); + assertEquals(15L, question.getEventId()); + assertEquals(2, question.getDisplayOrder()); + assertEquals("참여 종목", question.getQuestionText()); + assertEquals(QuestionType.MULTIPLE_CHOICE, question.getQuestionType()); + assertTrue(question.isRequired()); + assertEquals(List.of("축구", "농구"), question.getOptions()); + assertTrue(question.isOtherOptionAllowed()); + } + + @Test + @DisplayName("텍스트형 질문에 딸려 온 선택지와 '기타' 여부는 버린다") + void textQuestionDropsChoiceFields() { + for (QuestionType type : List.of(QuestionType.SHORT_TEXT, QuestionType.LONG_TEXT)) { + EventQuestion question = EventQuestion.create(15L, 1, command(type, List.of("축구"), true)); + + assertEquals(List.of(), question.getOptions()); + assertFalse(question.isOtherOptionAllowed()); + } + } + + @Test + @DisplayName("요청 목록을 나중에 바꿔도 질문의 선택지는 바뀌지 않는다") + void copiesOptions() { + List options = new ArrayList<>(List.of("축구")); + EventQuestion question = EventQuestion.create(15L, 1, command(QuestionType.SINGLE_CHOICE, options, false)); + + options.add("농구"); + + assertEquals(List.of("축구"), question.getOptions()); + } + } + + @Nested + @DisplayName("내용 비교") + class SameContent { + + @Test + @DisplayName("식별자·소속 행사·노출 순서 값이 달라도 신청서에 보이는 내용이 같으면 같다") + void ignoresIdentityAndOrderValue() { + EventQuestion saved = saved(101L, QuestionType.SINGLE_CHOICE, List.of("참석", "불참"), false); + EventQuestion requested = EventQuestion.create( + 15L, 5, command(QuestionType.SINGLE_CHOICE, List.of("참석", "불참"), false)); + + assertTrue(saved.hasSameContentAs(requested)); + } + + @Test + @DisplayName("유형이 다르면 다르다") + void differentType() { + assertFalse(saved(1L, QuestionType.SINGLE_CHOICE, List.of("참석"), false) + .hasSameContentAs(saved(1L, QuestionType.MULTIPLE_CHOICE, List.of("참석"), false))); + } + + @Test + @DisplayName("선택지 순서가 바뀌면 다르다 — 답변이 선택지 순서로 저장되기 때문이다") + void differentOptionOrder() { + assertFalse(saved(1L, QuestionType.SINGLE_CHOICE, List.of("참석", "불참"), false) + .hasSameContentAs(saved(1L, QuestionType.SINGLE_CHOICE, List.of("불참", "참석"), false))); + } + + @Test + @DisplayName("'기타' 여부가 다르면 다르다") + void differentOtherOption() { + assertFalse(saved(1L, QuestionType.SINGLE_CHOICE, List.of("참석"), false) + .hasSameContentAs(saved(1L, QuestionType.SINGLE_CHOICE, List.of("참석"), true))); + } + + @Test + @DisplayName("질문 내용이나 필수 여부가 다르면 다르다") + void differentTextOrRequired() { + EventQuestion base = saved(1L, QuestionType.SHORT_TEXT, List.of(), false); + + assertFalse(base.hasSameContentAs( + EventQuestion.of(1L, 15L, "전화번호", QuestionType.SHORT_TEXT, true, 1, List.of(), false))); + assertFalse(base.hasSameContentAs( + EventQuestion.of(1L, 15L, "참여 종목", QuestionType.SHORT_TEXT, false, 1, List.of(), false))); + } + } + + @Test + @DisplayName("선택형 유형은 단일·복수 선택뿐이다") + void choiceTypes() { + assertTrue(QuestionType.SINGLE_CHOICE.isChoice()); + assertTrue(QuestionType.MULTIPLE_CHOICE.isChoice()); + assertFalse(QuestionType.SHORT_TEXT.isChoice()); + assertFalse(QuestionType.LONG_TEXT.isChoice()); + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java index ca209808..0d66b174 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java @@ -23,7 +23,7 @@ private static Event event( return Event.of( 1L, "행사", "설명", "학생회관", NOW.plusDays(30), NOW.plusDays(31), applyStartAt, applyEndAt, - recruitType, imageIds, capacity, true, 100L + recruitType, imageIds, capacity, false, true, 100L, null ); } 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 cf7178b6..8c327aed 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 @@ -22,7 +22,7 @@ private static Event event(RecruitType recruitType) { 1L, "2026 소융대 개강 행사", "설명", "101호", LocalDateTime.of(2026, 9, 15, 18, 0), LocalDateTime.of(2026, 9, 15, 20, 0), APPLY_START_AT, APPLY_END_AT, - recruitType, List.of(), CAPACITY, true, 1L + recruitType, List.of(), CAPACITY, false, true, 1L, null ); } @@ -172,4 +172,121 @@ void notFull() { assertFalse(firstComeEvent().isClosedByCapacityOnly(APPLY_START_AT.plusDays(1), CAPACITY - 1)); } } + + @Nested + @DisplayName("운영진 등록") + class Create { + + private EventCreateCommand command(RecruitType recruitType, Integer capacity) { + return new EventCreateCommand( + "체육대회", "학과 대항 체육대회", "종합운동장", + LocalDateTime.of(2026, 11, 1, 10, 0), LocalDateTime.of(2026, 11, 1, 18, 0), + APPLY_START_AT, APPLY_END_AT, recruitType, capacity, true, List.of(31L, 32L), List.of() + ); + } + + @Test + @DisplayName("입력값을 그대로 담고 작성자를 기록한다") + void copiesCommand() { + Event event = Event.create(7L, command(RecruitType.FIRST_COME, 100)); + + assertNull(event.getId()); + assertEquals("체육대회", event.getTitle()); + assertEquals("학과 대항 체육대회", event.getDescription()); + assertEquals("종합운동장", event.getPlace()); + assertEquals(APPLY_START_AT, event.getApplyStartAt()); + assertEquals(APPLY_END_AT, event.getApplyEndAt()); + assertEquals(RecruitType.FIRST_COME, event.getRecruitType()); + assertEquals(100, event.getCapacity()); + assertTrue(event.isFeePaidOnly()); + assertEquals(List.of(31L, 32L), event.getImageIds()); + assertEquals(7L, event.getCreatedBy()); + } + + @Test + @DisplayName("학생에게 바로 보이지 않도록 비공개로 시작한다") + void startsUnpublished() { + assertFalse(Event.create(7L, command(RecruitType.FIRST_COME, 100)).isPublished()); + } + + @Test + @DisplayName("상시 모집은 입력한 정원과 상관없이 0으로 저장한다") + void openRecruitStoresZeroCapacity() { + assertEquals(0, Event.create(7L, command(RecruitType.OPEN, 50)).getCapacity()); + assertEquals(0, Event.create(7L, command(RecruitType.OPEN, null)).getCapacity()); + } + } + + @Nested + @DisplayName("운영진 수정") + class Update { + + private EventUpdateCommand command(RecruitType recruitType, Integer capacity) { + return new EventUpdateCommand( + "체육대회(변경)", "장소가 바뀌었습니다", "미래관 대강당", + LocalDateTime.of(2026, 11, 2, 10, 0), LocalDateTime.of(2026, 11, 2, 18, 0), + APPLY_START_AT.plusDays(1), APPLY_END_AT.plusDays(1), recruitType, capacity, true, + List.of(40L), List.of(), LocalDateTime.now() + ); + } + + @Test + @DisplayName("내용을 통째로 바꾼다") + void replacesContent() { + Event event = firstComeEvent(); + + event.update(command(RecruitType.FIRST_COME, 50)); + + assertEquals("체육대회(변경)", event.getTitle()); + assertEquals("장소가 바뀌었습니다", event.getDescription()); + assertEquals("미래관 대강당", event.getPlace()); + assertEquals(LocalDateTime.of(2026, 11, 2, 10, 0), event.getEventStartAt()); + assertEquals(LocalDateTime.of(2026, 11, 2, 18, 0), event.getEventEndAt()); + assertEquals(APPLY_START_AT.plusDays(1), event.getApplyStartAt()); + assertEquals(APPLY_END_AT.plusDays(1), event.getApplyEndAt()); + assertEquals(50, event.getCapacity()); + assertTrue(event.isFeePaidOnly()); + assertEquals(List.of(40L), event.getImageIds()); + } + + @Test + @DisplayName("공개 여부와 작성자는 그대로 둔다") + void keepsPublishedAndCreator() { + Event event = firstComeEvent(); + + event.update(command(RecruitType.FIRST_COME, 50)); + + assertTrue(event.isPublished()); + assertEquals(1L, event.getCreatedBy()); + assertEquals(1L, event.getId()); + } + + @Test + @DisplayName("상시 모집으로 바꾸면 정원을 0으로 둔다") + void switchToOpenRecruitClearsCapacity() { + Event event = firstComeEvent(); + + event.update(command(RecruitType.OPEN, 50)); + + assertEquals(RecruitType.OPEN, event.getRecruitType()); + assertEquals(0, event.getCapacity()); + } + } + + @Nested + @DisplayName("운영진 화면의 정원") + class CapacityLimit { + + @Test + @DisplayName("선착순이면 정원을 돌려준다") + void firstComeReturnsCapacity() { + assertEquals(CAPACITY, firstComeEvent().capacityLimit()); + } + + @Test + @DisplayName("상시 모집은 인원 제한이 없어 null이다") + void openRecruitReturnsNull() { + assertNull(event(RecruitType.OPEN).capacityLimit()); + } + } } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java index 115de48a..2e1c6602 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java @@ -24,7 +24,7 @@ class EventApplyAnswerValidatorTest { private final EventApplyAnswerValidator validator = new EventApplyAnswerValidator(); private static EventQuestion question(Long id, QuestionType type, boolean required, List options) { - return EventQuestion.of(id, 1L, "질문", type, required, 0, options); + return EventQuestion.of(id, 1L, "질문", type, required, 0, options, false); } /** 단답(필수) / 장문(선택) / 객관식 3지(필수) / 체크박스 2지(선택) */ 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 new file mode 100644 index 00000000..b4285436 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java @@ -0,0 +1,515 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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; +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.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; + +/** + * 운영진 행사 등록·조회·수정. 형식 규칙 자체는 {@code EventWriteValidatorTest}가 덮고, + * 여기서는 저장 흐름과 수정 시의 규칙(동시 수정·정원 하한·질문 잠금)을 본다. + */ +class EventServiceImplManageTest { + + private static final Long EVENT_ID = 15L; + private static final LocalDateTime UPDATED_AT = LocalDateTime.of(2026, 10, 10, 12, 30, 15); + private static final LocalDateTime APPLY_START_AT = LocalDateTime.of(2026, 10, 14, 10, 0); + private static final LocalDateTime APPLY_END_AT = LocalDateTime.of(2026, 10, 25, 23, 59, 59); + private static final LocalDateTime EVENT_START_AT = LocalDateTime.of(2026, 11, 1, 10, 0); + private static final LocalDateTime EVENT_END_AT = LocalDateTime.of(2026, 11, 1, 18, 0); + + private static final EventQuestionCommand SPORTS = new EventQuestionCommand( + "참여 종목", QuestionType.MULTIPLE_CHOICE, true, List.of("축구", "농구"), true); + private static final EventQuestionCommand PHONE = new EventQuestionCommand( + "전화번호", QuestionType.SHORT_TEXT, true, List.of(), false); + + private final InMemoryEventRepository repository = new InMemoryEventRepository(); + private final EventServiceImpl service = new EventServiceImpl(repository, new EventWriteValidator()); + + private static Event savedEvent(RecruitType recruitType, int capacity) { + return Event.of( + EVENT_ID, "체육대회", "설명", "종합운동장", EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, + recruitType, List.of(31L), capacity, false, true, 7L, UPDATED_AT + ); + } + + private static EventCreateCommand createCommand( + RecruitType recruitType, Integer capacity, List questions) { + return new EventCreateCommand( + "체육대회", "설명", "종합운동장", EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, + recruitType, capacity, false, List.of(31L), questions + ); + } + + private static EventUpdateCommand updateCommand( + RecruitType recruitType, Integer capacity, List questions, LocalDateTime expectedUpdatedAt) { + return new EventUpdateCommand( + "체육대회(변경)", "설명", "미래관", EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, + recruitType, capacity, true, List.of(40L), questions, expectedUpdatedAt + ); + } + + private static EventUpdateCommand updateCommand(List questions) { + return updateCommand(RecruitType.FIRST_COME, 100, questions, UPDATED_AT); + } + + /** 저장된 질문처럼 id가 붙은 질문 목록. 노출 순서는 1부터 센다. */ + private static List savedQuestions(EventQuestionCommand... commands) { + List questions = new ArrayList<>(); + for (int i = 0; i < commands.length; i++) { + EventQuestionCommand command = commands[i]; + questions.add(EventQuestion.of( + 100L + i, EVENT_ID, command.questionText(), command.questionType(), command.required(), i + 1, + command.options(), command.otherOptionAllowed())); + } + return questions; + } + + private static void assertRejected(EventErrorCode expected, Runnable action) { + BusinessException e = assertThrows(BusinessException.class, action::run); + assertEquals(expected, e.getErrorCode()); + } + + @Nested + @DisplayName("등록") + class Create { + + @Test + @DisplayName("행사를 비공개로 저장하고 작성자를 기록한다") + void savesUnpublishedEvent() { + Event created = service.create(7L, createCommand(RecruitType.FIRST_COME, 100, List.of())); + + Event saved = repository.events.get(created.getId()); + assertFalse(saved.isPublished()); + assertEquals(7L, saved.getCreatedBy()); + assertEquals(100, saved.getCapacity()); + } + + @Test + @DisplayName("질문을 요청 순서대로 1부터 노출 순서를 붙여 저장한다") + void savesQuestionsInRequestOrder() { + Event created = service.create(7L, createCommand(RecruitType.OPEN, null, List.of(PHONE, SPORTS))); + + List questions = repository.questions.get(created.getId()); + assertEquals(List.of("전화번호", "참여 종목"), + questions.stream().map(EventQuestion::getQuestionText).toList()); + assertEquals(List.of(1, 2), questions.stream().map(EventQuestion::getDisplayOrder).toList()); + assertTrue(questions.stream().allMatch(question -> question.getEventId().equals(created.getId()))); + } + + @Test + @DisplayName("질문이 없으면 빈 신청서로 저장한다") + void savesEmptyForm() { + Event created = service.create(7L, createCommand(RecruitType.OPEN, null, List.of())); + + assertTrue(repository.findQuestionsByEventId(created.getId()).isEmpty()); + } + + @Test + @DisplayName("규칙을 어기면 아무것도 저장하지 않는다") + void savesNothingWhenInvalid() { + assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, + () -> service.create(7L, createCommand(RecruitType.FIRST_COME, 0, List.of(SPORTS)))); + + assertTrue(repository.events.isEmpty()); + assertTrue(repository.questions.isEmpty()); + } + } + + @Nested + @DisplayName("상세 조회") + class GetEvent { + + @Test + @DisplayName("행사·질문·유효 신청자 수를 함께 돌려준다") + 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); + + EventManageDetail detail = service.getEvent(EVENT_ID); + + assertEquals(EVENT_ID, detail.event().getId()); + assertEquals(2, detail.questions().size()); + assertEquals(3L, detail.applicantCount()); + } + + @Test + @DisplayName("없거나 삭제된 행사는 찾을 수 없다") + void notFound() { + assertRejected(EventErrorCode.EVENT_NOT_FOUND, () -> service.getEvent(EVENT_ID)); + } + } + + @Nested + @DisplayName("수정") + class Update { + + private void givenEvent(RecruitType recruitType, int capacity, List questions) { + repository.events.put(EVENT_ID, savedEvent(recruitType, capacity)); + repository.questions.put(EVENT_ID, questions); + } + + @Test + @DisplayName("내용을 바꿔 저장하고 공개 여부·작성자는 그대로 둔다") + void updatesContent() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + + service.update(EVENT_ID, updateCommand(List.of(SPORTS))); + + Event saved = repository.events.get(EVENT_ID); + assertEquals("체육대회(변경)", saved.getTitle()); + assertEquals("미래관", saved.getPlace()); + assertEquals(List.of(40L), saved.getImageIds()); + assertTrue(saved.isFeePaidOnly()); + assertTrue(saved.isPublished()); + assertEquals(7L, saved.getCreatedBy()); + } + + @Test + @DisplayName("없거나 삭제된 행사는 찾을 수 없다") + void notFound() { + assertRejected(EventErrorCode.EVENT_NOT_FOUND, () -> service.update(EVENT_ID, updateCommand(List.of()))); + } + + @Nested + @DisplayName("동시 수정") + class Conflict { + + @Test + @DisplayName("수정 화면이 읽은 뒤 다른 운영진이 고쳤다면 거부한다") + void rejectsWhenModifiedMeanwhile() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + + assertRejected(EventErrorCode.EVENT_UPDATE_CONFLICT, () -> service.update(EVENT_ID, + updateCommand(RecruitType.FIRST_COME, 100, List.of(SPORTS), UPDATED_AT.minusSeconds(1)))); + assertEquals("체육대회", repository.events.get(EVENT_ID).getTitle()); + } + + @Test + @DisplayName("같은 초 안의 밀리초 차이는 같은 시각으로 본다 — DB가 초 단위로 저장한다") + void ignoresSubSecondDifference() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + + assertDoesNotThrow(() -> service.update(EVENT_ID, + updateCommand(RecruitType.FIRST_COME, 100, List.of(SPORTS), UPDATED_AT.plusNanos(999_000_000)))); + } + + @Test + @DisplayName("동시 수정 검사는 형식 검증보다 먼저 한다") + void checkedBeforeFormat() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + + assertRejected(EventErrorCode.EVENT_UPDATE_CONFLICT, () -> service.update(EVENT_ID, + updateCommand(RecruitType.FIRST_COME, 0, List.of(SPORTS), UPDATED_AT.minusDays(1)))); + } + } + + @Nested + @DisplayName("정원 하한") + class CapacityFloor { + + @Test + @DisplayName("선착순 정원을 현재 신청자 수보다 줄일 수 없다") + void rejectsBelowApplicants() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + repository.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))); + } + + @Test + @DisplayName("신청자 수와 같은 정원까지는 줄일 수 있다") + void allowsEqualToApplicants() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + repository.appliedCounts.put(EVENT_ID, 10L); + + service.update(EVENT_ID, updateCommand(RecruitType.FIRST_COME, 10, List.of(SPORTS), UPDATED_AT)); + + assertEquals(10, repository.events.get(EVENT_ID).getCapacity()); + } + + @Test + @DisplayName("상시 모집에서 선착순으로 바꿀 때도 신청자 수 이상이어야 한다") + void switchingToFirstComeChecksApplicants() { + givenEvent(RecruitType.OPEN, 0, savedQuestions(SPORTS)); + repository.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))); + } + + @Test + @DisplayName("상시 모집은 신청자 수와 상관없이 수정할 수 있다") + void openRecruitIgnoresApplicants() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + repository.appliedCounts.put(EVENT_ID, 300L); + + assertDoesNotThrow(() -> service.update(EVENT_ID, + updateCommand(RecruitType.OPEN, null, List.of(SPORTS), UPDATED_AT))); + } + } + + @Nested + @DisplayName("신청서 질문") + class Questions { + + @Test + @DisplayName("질문이 그대로면 교체하지 않아 질문 id가 유지된다") + void keepsQuestionsWhenUnchanged() { + List original = savedQuestions(PHONE, SPORTS); + givenEvent(RecruitType.FIRST_COME, 100, original); + repository.applicationExists = true; + + service.update(EVENT_ID, updateCommand(List.of(PHONE, SPORTS))); + + assertFalse(repository.questionsReplaced); + assertFalse(repository.updatedAtTouched); + assertSame(original, repository.questions.get(EVENT_ID)); + } + + @Test + @DisplayName("신청이 없으면 바뀐 질문으로 통째로 교체한다") + void replacesWhenNoApplication() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(PHONE, SPORTS)); + + service.update(EVENT_ID, updateCommand(List.of(SPORTS))); + + assertTrue(repository.questionsReplaced); + List replaced = repository.questions.get(EVENT_ID); + assertEquals(List.of("참여 종목"), replaced.stream().map(EventQuestion::getQuestionText).toList()); + assertEquals(1, replaced.getFirst().getDisplayOrder()); + assertNull(replaced.getFirst().getId()); + } + + @Test + @DisplayName("질문을 교체하면 행사 수정 시각도 올린다 — 질문만 바뀌면 DB가 시각을 올리지 않는다") + void touchesUpdatedAtWhenReplaced() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + + service.update(EVENT_ID, updateCommand(List.of(SPORTS, PHONE))); + + assertTrue(repository.updatedAtTouched); + } + + @Test + @DisplayName("순서만 바꿔도 바뀐 것으로 본다") + void reorderCountsAsChange() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(PHONE, SPORTS)); + + service.update(EVENT_ID, updateCommand(List.of(SPORTS, PHONE))); + + assertTrue(repository.questionsReplaced); + } + + @Test + @DisplayName("신청(취소 포함)이 있으면 질문을 바꿀 수 없고 행사도 저장하지 않는다") + void lockedWhenApplicationExists() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + repository.applicationExists = true; + + assertRejected(EventErrorCode.EVENT_QUESTIONS_LOCKED, + () -> service.update(EVENT_ID, updateCommand(List.of(SPORTS, PHONE)))); + assertFalse(repository.questionsReplaced); + assertEquals("체육대회", repository.events.get(EVENT_ID).getTitle()); + } + } + } + + @Nested + @DisplayName("목록 조회") + class GetEvents { + + @Test + @DisplayName("검색어와 페이지를 그대로 넘겨 행사 페이지를 돌려준다") + void delegatesToRepository() { + repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100)); + + PageResult page = service.getEvents("체육", PageOffset.of(1, 10)); + + assertEquals("체육", repository.requestedKeyword); + assertEquals(PageOffset.of(1, 10), repository.requestedPageOffset); + assertEquals(1, page.content().size()); + } + + @Test + @DisplayName("신청자 수는 행사 id 목록으로 한 번에 조회한다") + void countsApplicantsAtOnce() { + repository.appliedCounts.put(1L, 3L); + repository.appliedCounts.put(2L, 5L); + + assertEquals(Map.of(1L, 3L, 2L, 5L), service.getApplicantCounts(List.of(1L, 2L))); + } + } + + @Test + @DisplayName("상세 조회의 모집 상태 계산에 쓰는 신청자 수는 유효 신청만 센다") + 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); + + EventManageDetail detail = service.getEvent(EVENT_ID); + + assertEquals(RecruitStatus.CLOSED, + 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; + } + + @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 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 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(); + } + + @Override + public boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId) { + throw new UnsupportedOperationException(); + } + + @Override + public EventApplication saveApplication(EventApplication application) { + throw new UnsupportedOperationException(); + } + + @Override + public List saveAnswers(List answers) { + throw new UnsupportedOperationException(); + } + + @Override + public List findApplicationSlice(Long memberId, EventApplicationCursor cursor, int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional findApplicationByIdAndMemberId(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/EventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java index de178814..dc7afcef 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 @@ -10,6 +10,8 @@ import java.util.Map; import java.util.Optional; 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; @@ -34,7 +36,7 @@ private static Event event(Long id) { return Event.of( id, "행사 " + id, "설명", "101호", NOW.plusDays(10), NOW.plusDays(10).plusHours(2), NOW.minusDays(1), NOW.plusDays(3), - RecruitType.FIRST_COME, List.of(), 100, true, 1L + RecruitType.FIRST_COME, List.of(), 100, false, true, 1L, null ); } @@ -45,7 +47,7 @@ void matchesApplicantCountToItsEvent() { List.of(event(1L), event(2L)), Map.of(1L, 3L, 2L, 7L)); - List content = new EventServiceImpl(repository) + List content = new EventServiceImpl(repository, new EventWriteValidator()) .getPublishedEvents(null, null, 20).content(); // 정원 100명이라 신청자 수가 모집 상태를 바꾸지 않는다. 짝이 맞는지는 D-Day가 아니라 아래에서 직접 본다 @@ -61,7 +63,7 @@ void keepsEventsWithoutApplicants() { List.of(event(1L), event(2L)), Map.of(1L, 3L)); - List content = new EventServiceImpl(repository) + List content = new EventServiceImpl(repository, new EventWriteValidator()) .getPublishedEvents(null, null, 20).content(); assertEquals(2, content.size()); @@ -74,17 +76,17 @@ void appliesCountPerEvent() { Event full = Event.of( 1L, "정원 3명", "설명", "101호", NOW.plusDays(10), null, NOW.minusDays(1), NOW.plusDays(3), - RecruitType.FIRST_COME, List.of(), 3, true, 1L); + RecruitType.FIRST_COME, List.of(), 3, false, true, 1L, null); Event roomy = Event.of( 2L, "정원 100명", "설명", "101호", NOW.plusDays(10), null, NOW.minusDays(1), NOW.plusDays(3), - RecruitType.FIRST_COME, List.of(), 100, true, 1L); + RecruitType.FIRST_COME, List.of(), 100, false, true, 1L, null); FakeEventRepository repository = new FakeEventRepository( List.of(full, roomy), Map.of(1L, 3L, 2L, 3L)); - List content = new EventServiceImpl(repository) + List content = new EventServiceImpl(repository, new EventWriteValidator()) .getPublishedEvents(null, null, 20).content(); assertEquals(RecruitStatus.CLOSED, content.get(0).recruitStatus()); @@ -97,7 +99,7 @@ void detectsNextPage() { FakeEventRepository repository = new FakeEventRepository( List.of(event(1L), event(2L), event(3L)), Map.of()); - CursorSliceResult result = new EventServiceImpl(repository) + CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator()) .getPublishedEvents(null, null, 2); assertEquals(3, repository.requestedLimit); @@ -111,7 +113,7 @@ void detectsNextPage() { void noCursorOnLastPage() { FakeEventRepository repository = new FakeEventRepository(List.of(event(1L), event(2L)), Map.of()); - CursorSliceResult result = new EventServiceImpl(repository) + CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator()) .getPublishedEvents(null, null, 2); assertFalse(result.hasNext()); @@ -124,7 +126,7 @@ void asksNothingWhenSliceIsEmpty() { FakeEventRepository repository = new FakeEventRepository( List.of(), Map.of()); - CursorSliceResult result = new EventServiceImpl(repository) + CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator()) .getPublishedEvents(null, null, 20); assertTrue(result.content().isEmpty()); @@ -162,6 +164,36 @@ public Optional findById(Long id) { throw new UnsupportedOperationException(); } + @Override + public Event save(Event event) { + throw new UnsupportedOperationException(); + } + + @Override + public List saveQuestions(List questions) { + throw new UnsupportedOperationException(); + } + + @Override + public PageResult findPage(String keyword, PageOffset pageOffset) { + throw new UnsupportedOperationException(); + } + + @Override + public List replaceQuestions(Long eventId, List questions) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean existsApplicationByEventId(Long eventId) { + throw new UnsupportedOperationException(); + } + + @Override + public void touchUpdatedAt(Long eventId) { + throw new UnsupportedOperationException(); + } + @Override public Optional findPublishedById(Long id) { throw new UnsupportedOperationException(); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java new file mode 100644 index 00000000..37ff1230 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java @@ -0,0 +1,195 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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.QuestionType; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class EventWriteValidatorTest { + + private static final LocalDateTime APPLY_START_AT = LocalDateTime.of(2026, 10, 14, 10, 0); + private static final LocalDateTime APPLY_END_AT = LocalDateTime.of(2026, 10, 25, 23, 59, 59); + private static final LocalDateTime EVENT_START_AT = LocalDateTime.of(2026, 11, 1, 10, 0); + private static final LocalDateTime EVENT_END_AT = LocalDateTime.of(2026, 11, 1, 18, 0); + + private final EventWriteValidator validator = new EventWriteValidator(); + + private static EventCreateCommand createCommand( + LocalDateTime eventStartAt, + LocalDateTime eventEndAt, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitType recruitType, + Integer capacity, + List questions + ) { + return new EventCreateCommand( + "체육대회", "설명", "종합운동장", eventStartAt, eventEndAt, applyStartAt, applyEndAt, + recruitType, capacity, false, List.of(), questions + ); + } + + private static EventCreateCommand validCommand() { + return createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, 100, List.of()); + } + + private static EventQuestionCommand question(QuestionType type, List options) { + return new EventQuestionCommand("질문", type, true, options, false); + } + + private static void assertRejected(EventErrorCode expected, Runnable validation) { + BusinessException e = assertThrows(BusinessException.class, validation::run); + assertEquals(expected, e.getErrorCode()); + } + + @Test + @DisplayName("규칙을 모두 지키면 통과한다") + void validCommandPasses() { + assertDoesNotThrow(() -> validator.validate(validCommand())); + } + + @Nested + @DisplayName("기간") + class Period { + + @Test + @DisplayName("행사 종료가 시작보다 빠르면 거부한다") + void eventEndsBeforeStart() { + assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_START_AT.minusMinutes(1), APPLY_START_AT, APPLY_END_AT, + RecruitType.OPEN, null, List.of()))); + } + + @Test + @DisplayName("행사 시작과 종료가 같으면 거부한다") + void eventEndsAtStart() { + assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_START_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of()))); + } + + @Test + @DisplayName("신청 마감이 시작보다 빠르면 거부한다") + void applyEndsBeforeStart() { + assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_START_AT.minusSeconds(1), + RecruitType.OPEN, null, List.of()))); + } + + @Test + @DisplayName("신청 기간과 행사 기간이 겹쳐도 각각 순서만 맞으면 통과한다") + void overlappingPeriodsAreAllowed() { + assertDoesNotThrow(() -> validator.validate(createCommand( + APPLY_START_AT.plusDays(1), APPLY_END_AT.plusDays(1), APPLY_START_AT, APPLY_END_AT, + RecruitType.OPEN, null, List.of()))); + } + } + + @Nested + @DisplayName("정원") + class Capacity { + + @Test + @DisplayName("선착순인데 정원이 없으면 거부한다") + void firstComeWithoutCapacity() { + assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, null, List.of()))); + } + + @Test + @DisplayName("선착순인데 정원이 0명이면 거부한다") + void firstComeWithZeroCapacity() { + assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, 0, List.of()))); + } + + @Test + @DisplayName("선착순 정원은 1명부터 허용한다") + void firstComeWithOneCapacity() { + assertDoesNotThrow(() -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, 1, List.of()))); + } + + @Test + @DisplayName("상시 모집은 정원을 보지 않는다") + void openRecruitIgnoresCapacity() { + assertDoesNotThrow(() -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of()))); + assertDoesNotThrow(() -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, -1, List.of()))); + } + } + + @Nested + @DisplayName("신청서 질문") + class Questions { + + @Test + @DisplayName("선택형 질문에 선택지가 없으면 거부한다") + void choiceWithoutOptions() { + for (QuestionType type : List.of(QuestionType.SINGLE_CHOICE, QuestionType.MULTIPLE_CHOICE)) { + assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, + List.of(question(type, List.of()))))); + assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, + List.of(question(type, null))))); + } + } + + @Test + @DisplayName("여러 질문 중 하나라도 규칙을 어기면 거부한다") + void oneInvalidAmongMany() { + assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of( + question(QuestionType.SHORT_TEXT, List.of()), + question(QuestionType.SINGLE_CHOICE, List.of("참석", "불참")), + question(QuestionType.MULTIPLE_CHOICE, List.of()))))); + } + + @Test + @DisplayName("텍스트형 질문은 선택지가 없어도 통과한다") + void textWithoutOptions() { + assertDoesNotThrow(() -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of( + question(QuestionType.SHORT_TEXT, null), + question(QuestionType.LONG_TEXT, List.of()))))); + } + } + + @Nested + @DisplayName("수정 요청") + class UpdateCommand { + + @Test + @DisplayName("등록과 같은 규칙으로 검증한다") + void sameRulesAsCreate() { + EventUpdateCommand invalidPeriod = new EventUpdateCommand( + "체육대회", "설명", null, EVENT_START_AT, EVENT_START_AT, APPLY_START_AT, APPLY_END_AT, + RecruitType.OPEN, null, false, List.of(), List.of(), LocalDateTime.now()); + EventUpdateCommand invalidCapacity = new EventUpdateCommand( + "체육대회", "설명", null, EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, + RecruitType.FIRST_COME, 0, false, List.of(), List.of(), LocalDateTime.now()); + EventUpdateCommand invalidQuestion = new EventUpdateCommand( + "체육대회", "설명", null, EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, + RecruitType.OPEN, null, false, List.of(), List.of(question(QuestionType.SINGLE_CHOICE, List.of())), + LocalDateTime.now()); + + assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(invalidPeriod)); + assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, () -> validator.validate(invalidCapacity)); + assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(invalidQuestion)); + } + } +} 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 863e98e1..ff405012 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 @@ -13,6 +13,8 @@ public interface EventApplicationJpaRepository extends JpaRepository findByIdAndMemberId(Long id, Long memberId); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java index d33a051c..fef068c1 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java @@ -66,6 +66,9 @@ public class EventJpaEntity extends BaseSoftDeleteEntity { @Column(nullable = false) private int capacity; + @Column(name = "fee_paid_only", nullable = false) + private boolean feePaidOnly; + @Column(name = "is_published", nullable = false) private boolean isPublished; @@ -84,6 +87,7 @@ private EventJpaEntity(Event event) { this.recruitType = event.getRecruitType(); this.imageIds = event.getImageIds(); this.capacity = event.getCapacity(); + this.feePaidOnly = event.isFeePaidOnly(); this.isPublished = event.isPublished(); this.createdBy = event.getCreatedBy(); } @@ -94,7 +98,7 @@ public static EventJpaEntity from(Event event) { public Event toDomain() { return Event.of(id, title, description, place, eventStartAt, eventEndAt, - applyStartAt, applyEndAt, recruitType, imageIds, capacity, isPublished, - createdBy); + applyStartAt, applyEndAt, recruitType, imageIds, capacity, feePaidOnly, + isPublished, createdBy, getUpdatedAt()); } } 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 79d81881..dfc8848b 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 @@ -5,8 +5,10 @@ import java.util.Optional; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +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.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,6 +18,29 @@ public interface EventJpaRepository extends JpaRepository Optional findByIdAndIsDeletedFalseAndIsPublishedTrue(Long id); + /** + * 삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순으로 페이지 조회한다. 운영진 목록용이다. + */ + @Query( + value = """ + SELECT e FROM EventJpaEntity e + WHERE e.isDeleted = false + AND (:keyword IS NULL OR e.title LIKE CONCAT('%', :keyword, '%')) + ORDER BY e.id DESC + """, + countQuery = """ + SELECT COUNT(e) FROM EventJpaEntity e + WHERE e.isDeleted = false + AND (:keyword IS NULL OR e.title LIKE CONCAT('%', :keyword, '%')) + """ + ) + Page search(@Param("keyword") String keyword, Pageable pageable); + + // updated_at은 엔티티에서 읽기 전용(updatable = false)이라 네이티브 쿼리로 직접 올린다 + @Modifying + @Query(value = "UPDATE events SET updated_at = CURRENT_TIMESTAMP WHERE event_id = :eventId", nativeQuery = true) + void touchUpdatedAt(@Param("eventId") Long eventId); + /** * 게시된 행사를 (event_start_at, event_id) keyset 커서로 조회한다. *

diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaEntity.java index ef545f0e..4751c4dc 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaEntity.java @@ -53,6 +53,9 @@ public class EventQuestionJpaEntity extends BaseTimeEntity { @Column(columnDefinition = "json") private List options; + @Column(name = "has_other_option", nullable = false) + private boolean otherOptionAllowed; + private EventQuestionJpaEntity(EventQuestion question) { this.id = question.getId(); this.eventId = question.getEventId(); @@ -61,6 +64,7 @@ private EventQuestionJpaEntity(EventQuestion question) { this.required = question.isRequired(); this.displayOrder = question.getDisplayOrder(); this.options = question.getOptions(); + this.otherOptionAllowed = question.isOtherOptionAllowed(); } public static EventQuestionJpaEntity from(EventQuestion question) { @@ -68,6 +72,7 @@ public static EventQuestionJpaEntity from(EventQuestion question) { } public EventQuestion toDomain() { - return EventQuestion.of(id, eventId, questionText, questionType, required, displayOrder, options); + return EventQuestion.of(id, eventId, questionText, questionType, required, displayOrder, options, + otherOptionAllowed); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java index 5fea788b..d39e6f72 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java @@ -2,8 +2,15 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface EventQuestionJpaRepository extends JpaRepository { List findAllByEventIdOrderByDisplayOrderAsc(Long eventId); + + @Modifying + @Query("DELETE FROM EventQuestionJpaEntity q WHERE q.eventId = :eventId") + void deleteAllByEventId(@Param("eventId") Long eventId); } 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 58b9f5e1..b90bcb3a 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 @@ -5,6 +5,8 @@ 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; @@ -16,6 +18,8 @@ import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; 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; @@ -28,6 +32,51 @@ public class EventRepositoryImpl implements EventRepository { private final EventApplicationJpaRepository eventApplicationJpaRepository; private final EventApplicationAnswerJpaRepository eventApplicationAnswerJpaRepository; + @Override + public Event save(Event event) { + return eventJpaRepository.save(EventJpaEntity.from(event)).toDomain(); + } + + @Override + public List saveQuestions(List questions) { + List entities = questions.stream() + .map(EventQuestionJpaEntity::from) + .toList(); + return eventQuestionJpaRepository.saveAll(entities).stream() + .map(EventQuestionJpaEntity::toDomain) + .toList(); + } + + @Override + public List replaceQuestions(Long eventId, List questions) { + eventQuestionJpaRepository.deleteAllByEventId(eventId); + return saveQuestions(questions); + } + + @Override + public boolean existsApplicationByEventId(Long eventId) { + return eventApplicationJpaRepository.existsByEventId(eventId); + } + + @Override + public void touchUpdatedAt(Long eventId) { + eventJpaRepository.touchUpdatedAt(eventId); + } + + @Override + public PageResult findPage(String keyword, PageOffset pageOffset) { + Page result = eventJpaRepository.search( + keyword, PageRequest.of(pageOffset.page(), pageOffset.size()) + ); + + return PageResult.of( + result.getContent().stream().map(EventJpaEntity::toDomain).toList(), + result.getNumber(), + result.getSize(), + result.getTotalElements() + ); + } + @Override public Optional findById(Long id) { return eventJpaRepository.findByIdAndIsDeletedFalse(id).map(EventJpaEntity::toDomain); diff --git a/infrastructure/db/src/main/resources/db/migration/V19__add_fee_paid_only_and_has_other_option_to_events.sql b/infrastructure/db/src/main/resources/db/migration/V19__add_fee_paid_only_and_has_other_option_to_events.sql new file mode 100644 index 00000000..4b2a1c0a --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V19__add_fee_paid_only_and_has_other_option_to_events.sql @@ -0,0 +1,7 @@ +-- 학생회비 납부자만 신청할 수 있는 행사인지 +ALTER TABLE events + ADD COLUMN fee_paid_only TINYINT(1) NOT NULL DEFAULT 0; + +-- 선택형 질문에 '기타'(직접 입력) 선택지를 덧붙이는지 +ALTER TABLE event_questions + ADD COLUMN has_other_option TINYINT(1) NOT NULL DEFAULT 0;