Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventPublishRequest;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventSearchParams;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventUpdateRequest;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventListItemResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.EventPublishResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.EventUpdateResponse;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.dto.PageParams;
Expand All @@ -24,7 +26,7 @@
* <p>
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
*/
@Tag(name = "행사 관리", description = "운영진 행사 등록·조회·수정")
@Tag(name = "행사 관리", description = "운영진 행사 등록·조회·수정·공개 전환·삭제")
public interface AdminEventApi {

/** 행사 등록. 작성자는 토큰의 관리자로 정해진다. */
Expand Down Expand Up @@ -71,4 +73,19 @@ ApiResponse<PageResponse<AdminEventListItemResponse>> getEvents(
)
@ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"})
ApiResponse<EventUpdateResponse> updateEvent(AdminApiUser apiUser, Long eventId, EventUpdateRequest request);

/** 공개 상태 변경. 공개한 행사만 학생 앱 목록·상세에 보인다. */
@Operation(summary = "행사 공개 상태 변경",
description = "행사를 학생 앱에 공개하거나 비공개로 돌린다. 이미 그 상태여도 성공으로 응답한다. "
+ "비공개로 돌려도 기존 신청 내역은 유지된다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"})
ApiResponse<EventPublishResponse> changePublished(AdminApiUser apiUser, Long eventId, EventPublishRequest request);

/** 행사 삭제. 소프트 삭제라 데이터는 남지만 어디에도 보이지 않는다. */
@Operation(summary = "행사 삭제",
description = "행사를 삭제한다. 신청자가 있어도 지울 수 있으며, 행사와 그 신청 내역은 운영진 화면과 "
+ "학생 앱(내 신청 내역 포함)에서 보이지 않게 된다. 데이터는 지우지 않고 복구 기능은 없다.")
@ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"})
ApiResponse<Void> deleteEvent(AdminApiUser apiUser, Long eventId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package kr.ac.kookmin.stream.api.admin.event.event;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventApplicationSearchParams;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationListResponse;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.dto.PageParams;
import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
import kr.ac.kookmin.stream.common.CommonErrorCode;
import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode;
import org.springdoc.core.annotations.ParameterObject;

/**
* 운영진 행사 신청 현황 API의 문서 명세. 구현은 {@link AdminEventApplicationController}가 맡는다.
* <p>
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
*/
@Tag(name = "행사 신청 현황", description = "운영진 행사 신청자 목록·상세 조회")
public interface AdminEventApplicationApi {

/** 신청 현황. 한 줄에 신청자 정보와 질문별 답변을 함께 내려준다. */
@Operation(summary = "행사 신청 현황 조회",
description = "행사의 신청을 신청 순으로 페이지 단위 조회한다. 취소된 신청도 함께 내려가며, keyword로 이름·학번을, "
+ "status로 신청 상태를 거른다. 답변은 질문 순서대로 담기고, 선택형은 고른 선택지 문구를 쉼표로 잇는다. "
+ "탈퇴한 회원의 신청은 이름·학번이 비어 있다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND", "EVENT_INVALID_APPLICATION_STATUS"})
ApiResponse<AdminEventApplicationListResponse> getApplications(
AdminApiUser apiUser,
Long eventId,
@ParameterObject EventApplicationSearchParams params,
@ParameterObject PageParams pageParams
);

/** 신청자 상세. 표에서 잘린 답변을 질문 전체와 함께 확인한다. */
@Operation(summary = "행사 신청자 상세 조회",
description = "신청 한 건의 신청자 정보와, 행사 질문 전체에 이 신청의 답변을 붙여 조회한다. 답하지 않은 질문도 "
+ "빈 답변으로 함께 내려간다. 다른 행사의 신청이면 찾을 수 없다.")
@ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND", "APPLICATION_NOT_FOUND"})
ApiResponse<AdminEventApplicationDetailResponse> getApplication(AdminApiUser apiUser, Long eventId, Long applicationId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package kr.ac.kookmin.stream.api.admin.event.event;

import jakarta.validation.Valid;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventApplicationSearchParams;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationListResponse;
import kr.ac.kookmin.stream.api.admin.event.event.usecase.AdminEventApplicationQueryUseCase;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.dto.PageParams;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/admin/events/{eventId}/applications")
@RequiredArgsConstructor
public class AdminEventApplicationController implements AdminEventApplicationApi {

private final AdminEventApplicationQueryUseCase adminEventApplicationQueryUseCase;

@Override
@GetMapping
public ApiResponse<AdminEventApplicationListResponse> getApplications(
AdminApiUser apiUser,
@PathVariable Long eventId,
@Valid @ModelAttribute EventApplicationSearchParams params,
@Valid @ModelAttribute PageParams pageParams
) {
return ApiResponse.success(AdminEventApplicationListResponse.from(adminEventApplicationQueryUseCase.getApplicants(
eventId, params.toKeyword(), params.toStatus(), pageParams.toOffset())));
}

@Override
@GetMapping("/{applicationId}")
public ApiResponse<AdminEventApplicationDetailResponse> getApplication(
AdminApiUser apiUser,
@PathVariable Long eventId,
@PathVariable Long applicationId
) {
return ApiResponse.success(AdminEventApplicationDetailResponse.from(
adminEventApplicationQueryUseCase.getApplicant(eventId, applicationId)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
import java.util.Map;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventPublishRequest;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventSearchParams;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventUpdateRequest;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventListItemResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.EventPublishResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.EventUpdateResponse;
import kr.ac.kookmin.stream.api.admin.event.event.usecase.AdminEventWriteUseCase;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
Expand All @@ -22,8 +24,10 @@
import kr.ac.kookmin.stream.file.domain.File;
import kr.ac.kookmin.stream.file.service.FileService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
Expand Down Expand Up @@ -85,4 +89,22 @@ public ApiResponse<EventUpdateResponse> updateEvent(
Event event = adminEventWriteUseCase.update(eventId, request.toCommand());
return ApiResponse.success(EventUpdateResponse.from(event));
}

@Override
@PatchMapping("/{eventId}/publish")
public ApiResponse<EventPublishResponse> changePublished(
AdminApiUser apiUser,
@PathVariable Long eventId,
@Valid @RequestBody EventPublishRequest request
) {
Event event = eventService.changePublished(eventId, request.isPublished());
return ApiResponse.success(EventPublishResponse.from(event));
}

@Override
@DeleteMapping("/{eventId}")
public ApiResponse<Void> deleteEvent(AdminApiUser apiUser, @PathVariable Long eventId) {
eventService.delete(eventId);
return ApiResponse.success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.ac.kookmin.stream.api.admin.event.event.request;

import io.swagger.v3.oas.annotations.media.Schema;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;

public record EventApplicationSearchParams(
@Schema(description = "이름 또는 학번 검색어", example = "김학생")
String keyword,

@Schema(description = "신청 상태 필터. APPLIED(신청완료) 또는 CANCELED(취소). 없으면 전체", example = "APPLIED")
String status
) {

// 빈 검색어는 검색하지 않는 것으로 본다
public String toKeyword() {
return keyword == null || keyword.isBlank() ? null : keyword.strip();
}

public EventApplicationStatus toStatus() {
return EventApplicationStatus.from(status);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.ac.kookmin.stream.api.admin.event.event.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;

public record EventPublishRequest(
@Schema(description = "공개 여부. true면 학생 앱에 노출된다", example = "true")
@NotNull(message = "공개 여부를 입력해 주세요.")
Boolean isPublished
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package kr.ac.kookmin.stream.api.admin.event.event.response;

import java.time.LocalDateTime;
import java.util.List;
import kr.ac.kookmin.stream.api.admin.event.event.usecase.EventApplicantDetail;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationDetail;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;
import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion;
import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;

/**
* 신청자 상세(폼 조회). 신청 현황 표에서 잘린 답변을 질문 전체와 함께 보여준다.
*
* @param name 탈퇴한 회원의 신청이면 {@code null}
* @param studentId 탈퇴한 회원의 신청이면 {@code null}
*/
public record AdminEventApplicationDetailResponse(
Long applicationId,
String name,
String studentId,
LocalDateTime appliedAt,
EventApplicationStatus status,
LocalDateTime canceledAt,
List<AnsweredQuestion> questions
) {

public static AdminEventApplicationDetailResponse from(EventApplicantDetail applicant) {
Member member = applicant.member();
EventApplicationDetail detail = applicant.detail();
return new AdminEventApplicationDetailResponse(
applicant.application().getId(),
member == null ? null : member.getName(),
member == null ? null : member.getStudentId(),
detail.appliedAt(),
detail.applicationStatus(),
detail.canceledAt(),
detail.questions().stream().map(AnsweredQuestion::from).toList()
);
}

/**
* 질문 한 건과 이 신청의 답변. 학생 앱 내 신청 상세와 같은 모양이다.
*
* @param answerText 텍스트형 답변. 선택형이거나 답하지 않았으면 {@code null}
* @param selectedOptions 고른 선택지의 0부터 시작하는 순서 번호. 텍스트형이거나 답하지 않았으면 빈 배열
*/
public record AnsweredQuestion(
Long questionId,
String questionText,
QuestionType questionType,
boolean isRequired,
int displayOrder,
List<String> options,
String answerText,
List<Integer> selectedOptions
) {

public static AnsweredQuestion from(EventApplicationDetail.AnsweredQuestion answeredQuestion) {
EventQuestion question = answeredQuestion.question();
return new AnsweredQuestion(
question.getId(),
question.getQuestionText(),
question.getQuestionType(),
question.isRequired(),
question.getDisplayOrder(),
question.getOptions() == null ? List.of() : question.getOptions(),
answeredQuestion.answerText(),
answeredQuestion.selectedOptions()
);
}
}
}
Loading
Loading