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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,20 @@ 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", "EVENT_UNSUPPORTED_OPTION"})
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 = adminEventService.changePublished(eventId, request.isPublished());
return ApiResponse.success(EventPublishResponse.from(event));
}

@Override
@DeleteMapping("/{eventId}")
public ApiResponse<Void> deleteEvent(AdminApiUser apiUser, @PathVariable Long eventId) {
adminEventService.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