From 9d599b13d5b645b44f2d47d19eb643af5e27b719 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 3 Aug 2026 14:56:28 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EB=B6=80=EC=8A=A4=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EA=B3=B5=EA=B3=A0=20API=20=EC=B6=94=EA=B0=80=20(re?= =?UTF-8?q?cruitment)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 행사당 모집 공고 등록/수정/조기마감/완료처리 및 공개 목록·상세 조회 API 명세서(REC-API-001~007) 기준 경로/권한 구성 --- .../edu/booth/domain/BoothRecruitment.java | 71 +++++++ .../min/edu/common/config/SecurityConfig.java | 11 + .../edu/common/exception/GlobalErrorCode.java | 5 +- .../repository/EventMemberRepository.java | 14 ++ .../edu/event/repository/EventRepository.java | 8 + .../BoothRecruitmentController.java | 89 ++++++++ .../dto/BoothRecruitmentCreateRequestDto.java | 45 ++++ .../dto/BoothRecruitmentResponseDto.java | 58 ++++++ .../dto/BoothRecruitmentUpdateRequestDto.java | 45 ++++ .../BoothRecruitmentRepository.java | 19 ++ .../service/BoothRecruitmentService.java | 194 ++++++++++++++++++ 11 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 BE/src/main/java/com/min/edu/event/repository/EventMemberRepository.java create mode 100644 BE/src/main/java/com/min/edu/event/repository/EventRepository.java create mode 100644 BE/src/main/java/com/min/edu/recruitment/controller/BoothRecruitmentController.java create mode 100644 BE/src/main/java/com/min/edu/recruitment/dto/BoothRecruitmentCreateRequestDto.java create mode 100644 BE/src/main/java/com/min/edu/recruitment/dto/BoothRecruitmentResponseDto.java create mode 100644 BE/src/main/java/com/min/edu/recruitment/dto/BoothRecruitmentUpdateRequestDto.java create mode 100644 BE/src/main/java/com/min/edu/recruitment/repository/BoothRecruitmentRepository.java create mode 100644 BE/src/main/java/com/min/edu/recruitment/service/BoothRecruitmentService.java diff --git a/BE/src/main/java/com/min/edu/booth/domain/BoothRecruitment.java b/BE/src/main/java/com/min/edu/booth/domain/BoothRecruitment.java index e5a80891..47b7a71b 100644 --- a/BE/src/main/java/com/min/edu/booth/domain/BoothRecruitment.java +++ b/BE/src/main/java/com/min/edu/booth/domain/BoothRecruitment.java @@ -76,4 +76,75 @@ public class BoothRecruitment { @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; + + public static BoothRecruitment create( + Long eventId, + String title, + OffsetDateTime recruitmentStartAt, + OffsetDateTime recruitmentEndAt, + String participantTarget, + String qualification, + String selectionMethod, + OffsetDateTime expectedDecisionAt, + String contactName, + String contactEmail, + String contactPhone, + String notice, + OffsetDateTime now) { + return BoothRecruitment.builder() + .eventId(eventId) + .title(title) + .recruitmentStartAt(recruitmentStartAt) + .recruitmentEndAt(recruitmentEndAt) + .participantTarget(participantTarget) + .qualification(qualification) + .selectionMethod(selectionMethod) + .expectedDecisionAt(expectedDecisionAt) + .contactName(contactName) + .contactEmail(contactEmail) + .contactPhone(contactPhone) + .notice(notice) + .status(BoothRecruitmentStatus.BEFORE_OPEN) + .createdAt(now) + .updatedAt(now) + .build(); + } + + public void updateDetails( + String title, + OffsetDateTime recruitmentStartAt, + OffsetDateTime recruitmentEndAt, + String participantTarget, + String qualification, + String selectionMethod, + OffsetDateTime expectedDecisionAt, + String contactName, + String contactEmail, + String contactPhone, + String notice, + OffsetDateTime now) { + this.title = title; + this.recruitmentStartAt = recruitmentStartAt; + this.recruitmentEndAt = recruitmentEndAt; + this.participantTarget = participantTarget; + this.qualification = qualification; + this.selectionMethod = selectionMethod; + this.expectedDecisionAt = expectedDecisionAt; + this.contactName = contactName; + this.contactEmail = contactEmail; + this.contactPhone = contactPhone; + this.notice = notice; + this.updatedAt = now; + } + + public void changeStatus(BoothRecruitmentStatus newStatus, OffsetDateTime now) { + this.status = newStatus; + this.updatedAt = now; + } + + public void complete(OffsetDateTime now) { + this.status = BoothRecruitmentStatus.COMPLETED; + this.completedAt = now; + this.updatedAt = now; + } } diff --git a/BE/src/main/java/com/min/edu/common/config/SecurityConfig.java b/BE/src/main/java/com/min/edu/common/config/SecurityConfig.java index e28fa5b1..943aa737 100644 --- a/BE/src/main/java/com/min/edu/common/config/SecurityConfig.java +++ b/BE/src/main/java/com/min/edu/common/config/SecurityConfig.java @@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; @@ -44,6 +45,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/reissue", "/auth/logout").permitAll() .requestMatchers("/auth/me").authenticated() + .requestMatchers(HttpMethod.GET, "/booth-recruitments/**").permitAll() + .requestMatchers(HttpMethod.GET, "/events/*/booth-recruitment/management") + .authenticated() + .requestMatchers( + HttpMethod.POST, + "/events/*/booth-recruitment", + "/events/*/booth-recruitment/completion", + "/events/*/booth-recruitment/closure") + .authenticated() + .requestMatchers(HttpMethod.PATCH, "/events/*/booth-recruitment").authenticated() .anyRequest().permitAll()) .oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfo -> userInfo diff --git a/BE/src/main/java/com/min/edu/common/exception/GlobalErrorCode.java b/BE/src/main/java/com/min/edu/common/exception/GlobalErrorCode.java index 1a9bfd32..0edc3f42 100644 --- a/BE/src/main/java/com/min/edu/common/exception/GlobalErrorCode.java +++ b/BE/src/main/java/com/min/edu/common/exception/GlobalErrorCode.java @@ -27,7 +27,10 @@ public enum GlobalErrorCode { EVENT_NOT_FOUND(HttpStatus.NOT_FOUND, "EVENT_404_001", "행사를 찾을 수 없습니다."), ORDER_NUMBER_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "TICKET_500_001", "주문번호 생성에 실패했습니다."), EXCHANGE_CODE_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ADMISSION_500_001", "교환 코드 생성에 실패했습니다."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_500", "서버 내부 오류가 발생했습니다."); + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_500", "서버 내부 오류가 발생했습니다."), + RECRUITMENT_PERIOD_INVALID(HttpStatus.BAD_REQUEST, "RECRUITMENT_400_001", "모집 종료일은 시작일 이후여야 합니다."), + RECRUITMENT_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "RECRUITMENT_400_002", "이미 모집 공고가 등록된 행사입니다."), + RECRUITMENT_STATUS_TRANSITION_INVALID(HttpStatus.BAD_REQUEST, "RECRUITMENT_400_003", "허용되지 않는 상태 변경입니다."); private final HttpStatus status; private final String code; diff --git a/BE/src/main/java/com/min/edu/event/repository/EventMemberRepository.java b/BE/src/main/java/com/min/edu/event/repository/EventMemberRepository.java new file mode 100644 index 00000000..9afe8476 --- /dev/null +++ b/BE/src/main/java/com/min/edu/event/repository/EventMemberRepository.java @@ -0,0 +1,14 @@ +package com.min.edu.event.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.min.edu.event.domain.EventMember; +import com.min.edu.event.domain.EventRole; + +public interface EventMemberRepository extends JpaRepository { + boolean existsByEventIdAndMemberIdAndEventRoleAndActiveTrue( + Long eventId, + Long memberId, + EventRole eventRole + ); +} diff --git a/BE/src/main/java/com/min/edu/event/repository/EventRepository.java b/BE/src/main/java/com/min/edu/event/repository/EventRepository.java new file mode 100644 index 00000000..4b8e91a6 --- /dev/null +++ b/BE/src/main/java/com/min/edu/event/repository/EventRepository.java @@ -0,0 +1,8 @@ +package com.min.edu.event.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.min.edu.event.domain.Event; + +public interface EventRepository extends JpaRepository { +} diff --git a/BE/src/main/java/com/min/edu/recruitment/controller/BoothRecruitmentController.java b/BE/src/main/java/com/min/edu/recruitment/controller/BoothRecruitmentController.java new file mode 100644 index 00000000..a070d31b --- /dev/null +++ b/BE/src/main/java/com/min/edu/recruitment/controller/BoothRecruitmentController.java @@ -0,0 +1,89 @@ +package com.min.edu.recruitment.controller; + +import java.util.List; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.min.edu.auth.dto.AuthenticatedMemberDto; +import com.min.edu.booth.domain.BoothRecruitmentStatus; +import com.min.edu.common.response.ApiResponse; +import com.min.edu.recruitment.dto.BoothRecruitmentCreateRequestDto; +import com.min.edu.recruitment.dto.BoothRecruitmentResponseDto; +import com.min.edu.recruitment.dto.BoothRecruitmentUpdateRequestDto; +import com.min.edu.recruitment.service.BoothRecruitmentService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class BoothRecruitmentController { + + private final BoothRecruitmentService boothRecruitmentService; + + // REC-API-001 + @GetMapping("/booth-recruitments") + public ApiResponse> list( + @RequestParam(required = false) BoothRecruitmentStatus status) { + return ApiResponse.success(boothRecruitmentService.listPublic(status)); + } + + // REC-API-002 + @GetMapping("/booth-recruitments/{recruitmentId}") + public ApiResponse getDetail(@PathVariable Long recruitmentId) { + return ApiResponse.success(boothRecruitmentService.getPublicDetail(recruitmentId)); + } + + // REC-API-003 + @PostMapping("/events/{eventId}/booth-recruitment") + public ApiResponse create( + @PathVariable Long eventId, + @Valid @RequestBody BoothRecruitmentCreateRequestDto request, + @AuthenticationPrincipal AuthenticatedMemberDto member) { + return ApiResponse.success( + boothRecruitmentService.create(eventId, request, member) + ); + } + + // REC-API-004 + @GetMapping("/events/{eventId}/booth-recruitment/management") + public ApiResponse getManagement( + @PathVariable Long eventId, + @AuthenticationPrincipal AuthenticatedMemberDto member) { + return ApiResponse.success(boothRecruitmentService.getManagement(eventId, member)); + } + + // REC-API-005 + @PatchMapping("/events/{eventId}/booth-recruitment") + public ApiResponse update( + @PathVariable Long eventId, + @Valid @RequestBody BoothRecruitmentUpdateRequestDto request, + @AuthenticationPrincipal AuthenticatedMemberDto member) { + return ApiResponse.success( + boothRecruitmentService.update(eventId, request, member) + ); + } + + // REC-API-006 + @PostMapping("/events/{eventId}/booth-recruitment/completion") + public ApiResponse complete( + @PathVariable Long eventId, + @AuthenticationPrincipal AuthenticatedMemberDto member) { + return ApiResponse.success(boothRecruitmentService.complete(eventId, member)); + } + + // REC-API-007 + @PostMapping("/events/{eventId}/booth-recruitment/closure") + public ApiResponse close( + @PathVariable Long eventId, + @AuthenticationPrincipal AuthenticatedMemberDto member) { + return ApiResponse.success(boothRecruitmentService.close(eventId, member)); + } +} diff --git a/BE/src/main/java/com/min/edu/recruitment/dto/BoothRecruitmentCreateRequestDto.java b/BE/src/main/java/com/min/edu/recruitment/dto/BoothRecruitmentCreateRequestDto.java new file mode 100644 index 00000000..7a951c3c --- /dev/null +++ b/BE/src/main/java/com/min/edu/recruitment/dto/BoothRecruitmentCreateRequestDto.java @@ -0,0 +1,45 @@ +package com.min.edu.recruitment.dto; + +import java.time.OffsetDateTime; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class BoothRecruitmentCreateRequestDto { + + @NotBlank + private String title; + + @NotNull + private OffsetDateTime recruitmentStartAt; + + @NotNull + private OffsetDateTime recruitmentEndAt; + + @NotBlank + private String participantTarget; + + private String qualification; + + private String selectionMethod; + + private OffsetDateTime expectedDecisionAt; + + @NotBlank + private String contactName; + + @NotBlank + @Email + private String contactEmail; + + @NotBlank + private String contactPhone; + + private String notice; +} diff --git a/BE/src/main/java/com/min/edu/recruitment/dto/BoothRecruitmentResponseDto.java b/BE/src/main/java/com/min/edu/recruitment/dto/BoothRecruitmentResponseDto.java new file mode 100644 index 00000000..aa1a8b2a --- /dev/null +++ b/BE/src/main/java/com/min/edu/recruitment/dto/BoothRecruitmentResponseDto.java @@ -0,0 +1,58 @@ +package com.min.edu.recruitment.dto; + +import java.time.OffsetDateTime; + +import com.min.edu.booth.domain.BoothRecruitment; +import com.min.edu.booth.domain.BoothRecruitmentStatus; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BoothRecruitmentResponseDto { + + private Long id; + private Long eventId; + private String title; + private OffsetDateTime recruitmentStartAt; + private OffsetDateTime recruitmentEndAt; + private String participantTarget; + private String qualification; + private String selectionMethod; + private OffsetDateTime expectedDecisionAt; + private String contactName; + private String contactEmail; + private String contactPhone; + private String notice; + private BoothRecruitmentStatus status; + private OffsetDateTime completedAt; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public static BoothRecruitmentResponseDto from(BoothRecruitment recruitment) { + return BoothRecruitmentResponseDto.builder() + .id(recruitment.getId()) + .eventId(recruitment.getEventId()) + .title(recruitment.getTitle()) + .recruitmentStartAt(recruitment.getRecruitmentStartAt()) + .recruitmentEndAt(recruitment.getRecruitmentEndAt()) + .participantTarget(recruitment.getParticipantTarget()) + .qualification(recruitment.getQualification()) + .selectionMethod(recruitment.getSelectionMethod()) + .expectedDecisionAt(recruitment.getExpectedDecisionAt()) + .contactName(recruitment.getContactName()) + .contactEmail(recruitment.getContactEmail()) + .contactPhone(recruitment.getContactPhone()) + .notice(recruitment.getNotice()) + .status(recruitment.getStatus()) + .completedAt(recruitment.getCompletedAt()) + .createdAt(recruitment.getCreatedAt()) + .updatedAt(recruitment.getUpdatedAt()) + .build(); + } +} diff --git a/BE/src/main/java/com/min/edu/recruitment/dto/BoothRecruitmentUpdateRequestDto.java b/BE/src/main/java/com/min/edu/recruitment/dto/BoothRecruitmentUpdateRequestDto.java new file mode 100644 index 00000000..04bc7977 --- /dev/null +++ b/BE/src/main/java/com/min/edu/recruitment/dto/BoothRecruitmentUpdateRequestDto.java @@ -0,0 +1,45 @@ +package com.min.edu.recruitment.dto; + +import java.time.OffsetDateTime; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class BoothRecruitmentUpdateRequestDto { + + @NotBlank + private String title; + + @NotNull + private OffsetDateTime recruitmentStartAt; + + @NotNull + private OffsetDateTime recruitmentEndAt; + + @NotBlank + private String participantTarget; + + private String qualification; + + private String selectionMethod; + + private OffsetDateTime expectedDecisionAt; + + @NotBlank + private String contactName; + + @NotBlank + @Email + private String contactEmail; + + @NotBlank + private String contactPhone; + + private String notice; +} diff --git a/BE/src/main/java/com/min/edu/recruitment/repository/BoothRecruitmentRepository.java b/BE/src/main/java/com/min/edu/recruitment/repository/BoothRecruitmentRepository.java new file mode 100644 index 00000000..a9fb9001 --- /dev/null +++ b/BE/src/main/java/com/min/edu/recruitment/repository/BoothRecruitmentRepository.java @@ -0,0 +1,19 @@ +package com.min.edu.recruitment.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.min.edu.booth.domain.BoothRecruitment; +import com.min.edu.booth.domain.BoothRecruitmentStatus; + +public interface BoothRecruitmentRepository extends JpaRepository { + Optional findByEventId(Long eventId); + + boolean existsByEventId(Long eventId); + + List findAllByStatus(BoothRecruitmentStatus status); + + List findAllByStatusNot(BoothRecruitmentStatus status); +} diff --git a/BE/src/main/java/com/min/edu/recruitment/service/BoothRecruitmentService.java b/BE/src/main/java/com/min/edu/recruitment/service/BoothRecruitmentService.java new file mode 100644 index 00000000..87cbc92a --- /dev/null +++ b/BE/src/main/java/com/min/edu/recruitment/service/BoothRecruitmentService.java @@ -0,0 +1,194 @@ +package com.min.edu.recruitment.service; + +import java.time.OffsetDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.min.edu.auth.dto.AuthenticatedMemberDto; +import com.min.edu.booth.domain.BoothRecruitment; +import com.min.edu.booth.domain.BoothRecruitmentStatus; +import com.min.edu.common.exception.BusinessException; +import com.min.edu.common.exception.GlobalErrorCode; +import com.min.edu.event.domain.EventRole; +import com.min.edu.event.repository.EventMemberRepository; +import com.min.edu.event.repository.EventRepository; +import com.min.edu.member.domain.PlatformRole; +import com.min.edu.recruitment.dto.BoothRecruitmentCreateRequestDto; +import com.min.edu.recruitment.dto.BoothRecruitmentResponseDto; +import com.min.edu.recruitment.dto.BoothRecruitmentUpdateRequestDto; +import com.min.edu.recruitment.repository.BoothRecruitmentRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class BoothRecruitmentService { + + private final BoothRecruitmentRepository boothRecruitmentRepository; + private final EventRepository eventRepository; + private final EventMemberRepository eventMemberRepository; + + @Transactional + public BoothRecruitmentResponseDto create( + Long eventId, + BoothRecruitmentCreateRequestDto request, + AuthenticatedMemberDto member) { + eventRepository.findById(eventId) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.ENTITY_NOT_FOUND)); + + requireEventManager(eventId, member); + validatePeriod(request.getRecruitmentStartAt(), request.getRecruitmentEndAt()); + + if (boothRecruitmentRepository.existsByEventId(eventId)) { + throw new BusinessException(GlobalErrorCode.RECRUITMENT_ALREADY_EXISTS); + } + + OffsetDateTime now = OffsetDateTime.now(); + BoothRecruitment recruitment = BoothRecruitment.create( + eventId, + request.getTitle(), + request.getRecruitmentStartAt(), + request.getRecruitmentEndAt(), + request.getParticipantTarget(), + request.getQualification(), + request.getSelectionMethod(), + request.getExpectedDecisionAt(), + request.getContactName(), + request.getContactEmail(), + request.getContactPhone(), + request.getNotice(), + now + ); + + boothRecruitmentRepository.save(recruitment); + + return BoothRecruitmentResponseDto.from(recruitment); + } + + @Transactional + public BoothRecruitmentResponseDto update( + Long eventId, + BoothRecruitmentUpdateRequestDto request, + AuthenticatedMemberDto member) { + BoothRecruitment recruitment = getByEventIdOrThrow(eventId); + + requireEventManager(eventId, member); + validatePeriod(request.getRecruitmentStartAt(), request.getRecruitmentEndAt()); + + recruitment.updateDetails( + request.getTitle(), + request.getRecruitmentStartAt(), + request.getRecruitmentEndAt(), + request.getParticipantTarget(), + request.getQualification(), + request.getSelectionMethod(), + request.getExpectedDecisionAt(), + request.getContactName(), + request.getContactEmail(), + request.getContactPhone(), + request.getNotice(), + OffsetDateTime.now() + ); + + return BoothRecruitmentResponseDto.from(recruitment); + } + + @Transactional + public BoothRecruitmentResponseDto close(Long eventId, AuthenticatedMemberDto member) { + BoothRecruitment recruitment = getByEventIdOrThrow(eventId); + + requireEventManager(eventId, member); + + if (recruitment.getStatus() != BoothRecruitmentStatus.BEFORE_OPEN + && recruitment.getStatus() != BoothRecruitmentStatus.OPEN) { + throw new BusinessException(GlobalErrorCode.RECRUITMENT_STATUS_TRANSITION_INVALID); + } + + recruitment.changeStatus(BoothRecruitmentStatus.CLOSED, OffsetDateTime.now()); + + return BoothRecruitmentResponseDto.from(recruitment); + } + + @Transactional + public BoothRecruitmentResponseDto complete(Long eventId, AuthenticatedMemberDto member) { + BoothRecruitment recruitment = getByEventIdOrThrow(eventId); + + requireEventManager(eventId, member); + + if (recruitment.getStatus() != BoothRecruitmentStatus.CLOSED) { + throw new BusinessException(GlobalErrorCode.RECRUITMENT_STATUS_TRANSITION_INVALID); + } + + recruitment.complete(OffsetDateTime.now()); + + return BoothRecruitmentResponseDto.from(recruitment); + } + + @Transactional(readOnly = true) + public BoothRecruitmentResponseDto getManagement(Long eventId, AuthenticatedMemberDto member) { + BoothRecruitment recruitment = getByEventIdOrThrow(eventId); + + requireEventManager(eventId, member); + + return BoothRecruitmentResponseDto.from(recruitment); + } + + @Transactional(readOnly = true) + public BoothRecruitmentResponseDto getPublicDetail(Long recruitmentId) { + BoothRecruitment recruitment = boothRecruitmentRepository.findById(recruitmentId) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.ENTITY_NOT_FOUND)); + + if (recruitment.getStatus() == BoothRecruitmentStatus.BEFORE_OPEN) { + throw new BusinessException(GlobalErrorCode.ENTITY_NOT_FOUND); + } + + return BoothRecruitmentResponseDto.from(recruitment); + } + + @Transactional(readOnly = true) + public List listPublic(BoothRecruitmentStatus status) { + List recruitments; + + if (status == null) { + recruitments = boothRecruitmentRepository.findAllByStatusNot(BoothRecruitmentStatus.BEFORE_OPEN); + } else if (status == BoothRecruitmentStatus.BEFORE_OPEN) { + recruitments = List.of(); + } else { + recruitments = boothRecruitmentRepository.findAllByStatus(status); + } + + return recruitments.stream() + .map(BoothRecruitmentResponseDto::from) + .toList(); + } + + private BoothRecruitment getByEventIdOrThrow(Long eventId) { + return boothRecruitmentRepository.findByEventId(eventId) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.ENTITY_NOT_FOUND)); + } + + private void requireEventManager(Long eventId, AuthenticatedMemberDto member) { + if (member.getPlatformRole() == PlatformRole.PLATFORM_ADMIN) { + return; + } + + boolean isEventManager = eventMemberRepository + .existsByEventIdAndMemberIdAndEventRoleAndActiveTrue( + eventId, + member.getMemberId(), + EventRole.EVENT_MANAGER + ); + + if (!isEventManager) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN); + } + } + + private void validatePeriod(OffsetDateTime startAt, OffsetDateTime endAt) { + if (!endAt.isAfter(startAt)) { + throw new BusinessException(GlobalErrorCode.RECRUITMENT_PERIOD_INVALID); + } + } +} From 426bb06467be4d12e2e0786531b91ab887ce0a4c Mon Sep 17 00:00:00 2001 From: min Date: Mon, 3 Aug 2026 15:14:33 +0900 Subject: [PATCH 2/6] =?UTF-8?q?test:=20=EB=B6=80=EC=8A=A4=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EA=B3=B5=EA=B3=A0=20API=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testcontainers 기반으로 생성/권한/중복/공개목록/상태전환 시나리오 검증 TestcontainersConfiguration을 public으로 변경해 다른 패키지 테스트에서 재사용 --- .../min/edu/TestcontainersConfiguration.java | 2 +- .../BoothRecruitmentControllerTest.java | 229 ++++++++++++++++++ BE/src/test/resources/application.properties | 9 + 3 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 BE/src/test/java/com/min/edu/recruitment/controller/BoothRecruitmentControllerTest.java create mode 100644 BE/src/test/resources/application.properties diff --git a/BE/src/test/java/com/min/edu/TestcontainersConfiguration.java b/BE/src/test/java/com/min/edu/TestcontainersConfiguration.java index 401e70f3..5368a5bd 100644 --- a/BE/src/test/java/com/min/edu/TestcontainersConfiguration.java +++ b/BE/src/test/java/com/min/edu/TestcontainersConfiguration.java @@ -8,7 +8,7 @@ import org.testcontainers.utility.DockerImageName; @TestConfiguration(proxyBeanMethods = false) -class TestcontainersConfiguration { +public class TestcontainersConfiguration { @Bean @ServiceConnection diff --git a/BE/src/test/java/com/min/edu/recruitment/controller/BoothRecruitmentControllerTest.java b/BE/src/test/java/com/min/edu/recruitment/controller/BoothRecruitmentControllerTest.java new file mode 100644 index 00000000..29cf1661 --- /dev/null +++ b/BE/src/test/java/com/min/edu/recruitment/controller/BoothRecruitmentControllerTest.java @@ -0,0 +1,229 @@ +package com.min.edu.recruitment.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.OffsetDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.min.edu.TestcontainersConfiguration; +import com.min.edu.common.security.jwt.JwtTokenProvider; +import com.min.edu.event.domain.Event; +import com.min.edu.event.domain.EventMember; +import com.min.edu.event.domain.EventRole; +import com.min.edu.event.domain.EventStatus; +import com.min.edu.event.repository.EventMemberRepository; +import com.min.edu.event.repository.EventRepository; +import com.min.edu.member.domain.Member; +import com.min.edu.member.domain.OauthProvider; +import com.min.edu.member.domain.PlatformRole; +import com.min.edu.member.repository.MemberRepository; +import com.min.edu.organization.domain.Organization; +import com.min.edu.organization.domain.OrganizationStatus; +import com.min.edu.organization.domain.OrganizationType; + +import jakarta.persistence.EntityManager; + +@Import(TestcontainersConfiguration.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@AutoConfigureMockMvc +@Transactional +class BoothRecruitmentControllerTest { + + @Autowired + private MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Autowired + private EntityManager entityManager; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private EventRepository eventRepository; + + @Autowired + private EventMemberRepository eventMemberRepository; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + private Long eventId; + private String eventManagerToken; + private String outsiderToken; + + @BeforeEach + void setUp() { + OffsetDateTime now = OffsetDateTime.now(); + + Organization organization = Organization.builder() + .organizationType(OrganizationType.ORGANIZER) + .name("테스트기획사") + .contactEmail("org@example.com") + .contactPhone("010-0000-0000") + .status(OrganizationStatus.ACTIVE) + .createdAt(now) + .updatedAt(now) + .build(); + entityManager.persist(organization); + + Event event = Event.builder() + .organizerOrganizationId(organization.getId()) + .name("테스트 박람회") + .eventType("EXHIBITION") + .description("설명") + .venueName("테스트홀") + .address("서울시 어딘가") + .startAt(now.plusDays(30)) + .endAt(now.plusDays(32)) + .ticketPrice(java.math.BigDecimal.ZERO) + .ticketTotalQuantity(100) + .ticketSoldQuantity(0) + .ticketPurchaseLimit(5) + .status(EventStatus.PREPARING) + .boothRecruitmentEnabled(true) + .venueMapEnabled(true) + .boothReservationEnabled(true) + .noShowGraceMinutes(10) + .createdAt(now) + .updatedAt(now) + .build(); + eventRepository.save(event); + eventId = event.getId(); + + Member eventManager = Member.createOAuthMember( + "manager@example.com", "매니저", OauthProvider.GOOGLE, "manager-sub", now + ); + memberRepository.save(eventManager); + + Member outsider = Member.createOAuthMember( + "outsider@example.com", "외부인", OauthProvider.GOOGLE, "outsider-sub", now + ); + memberRepository.save(outsider); + + EventMember eventMember = EventMember.builder() + .eventId(eventId) + .memberId(eventManager.getId()) + .eventRole(EventRole.EVENT_MANAGER) + .active(true) + .createdAt(now) + .build(); + entityManager.persist(eventMember); + + entityManager.flush(); + + eventManagerToken = jwtTokenProvider.createAccessToken(eventManager.getId(), PlatformRole.USER); + outsiderToken = jwtTokenProvider.createAccessToken(outsider.getId(), PlatformRole.USER); + } + + @Test + void 담당자는_모집공고를_생성할_수_있다() throws Exception { + mockMvc.perform(post("/events/{eventId}/booth-recruitment", eventId) + .header("Authorization", "Bearer " + eventManagerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(createRequestJson())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.eventId").value(eventId)) + .andExpect(jsonPath("$.data.status").value("BEFORE_OPEN")); + } + + @Test + void 담당자가_아니면_생성할_수_없다() throws Exception { + mockMvc.perform(post("/events/{eventId}/booth-recruitment", eventId) + .header("Authorization", "Bearer " + outsiderToken) + .contentType(MediaType.APPLICATION_JSON) + .content(createRequestJson())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("COMMON_403")); + } + + @Test + void 같은_행사에_중복_생성하면_실패한다() throws Exception { + mockMvc.perform(post("/events/{eventId}/booth-recruitment", eventId) + .header("Authorization", "Bearer " + eventManagerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(createRequestJson())) + .andExpect(status().isOk()); + + mockMvc.perform(post("/events/{eventId}/booth-recruitment", eventId) + .header("Authorization", "Bearer " + eventManagerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(createRequestJson())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("RECRUITMENT_400_002")); + } + + @Test + void BEFORE_OPEN_상태는_공개_목록에_보이지_않는다() throws Exception { + mockMvc.perform(post("/events/{eventId}/booth-recruitment", eventId) + .header("Authorization", "Bearer " + eventManagerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(createRequestJson())) + .andExpect(status().isOk()); + + mockMvc.perform(get("/booth-recruitments")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(0)); + + mockMvc.perform(get("/events/{eventId}/booth-recruitment/management", eventId) + .header("Authorization", "Bearer " + eventManagerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("BEFORE_OPEN")); + } + + @Test + void 조기마감_후_완료처리_흐름() throws Exception { + mockMvc.perform(post("/events/{eventId}/booth-recruitment", eventId) + .header("Authorization", "Bearer " + eventManagerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(createRequestJson())) + .andExpect(status().isOk()); + + mockMvc.perform(post("/events/{eventId}/booth-recruitment/completion", eventId) + .header("Authorization", "Bearer " + eventManagerToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("RECRUITMENT_400_003")); + + mockMvc.perform(post("/events/{eventId}/booth-recruitment/closure", eventId) + .header("Authorization", "Bearer " + eventManagerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("CLOSED")); + + mockMvc.perform(post("/events/{eventId}/booth-recruitment/completion", eventId) + .header("Authorization", "Bearer " + eventManagerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("COMPLETED")); + } + + private String createRequestJson() throws Exception { + OffsetDateTime start = OffsetDateTime.now().plusDays(1); + OffsetDateTime end = OffsetDateTime.now().plusDays(10); + + return objectMapper.writeValueAsString(new java.util.LinkedHashMap() {{ + put("title", "테스트 모집 공고"); + put("recruitmentStartAt", start.toString()); + put("recruitmentEndAt", end.toString()); + put("participantTarget", "IT 기업"); + put("contactName", "홍길동"); + put("contactEmail", "contact@example.com"); + put("contactPhone", "010-1234-5678"); + }}); + } +} diff --git a/BE/src/test/resources/application.properties b/BE/src/test/resources/application.properties new file mode 100644 index 00000000..1be7251d --- /dev/null +++ b/BE/src/test/resources/application.properties @@ -0,0 +1,9 @@ +spring.security.oauth2.client.registration.google.client-id=test-client-id +spring.security.oauth2.client.registration.google.client-secret=test-client-secret + +jwt.secret=test-jwt-secret-key-for-integration-tests-do-not-use-in-production-environment +jwt.access-token-expiration=3600000 +jwt.refresh-token-expiration=1209600000 + +app.frontend-url=http://localhost:5173 +app.cookie-secure=false From 2b235cdefa4f49c24ac1062fb3df50a8a6d60df4 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 3 Aug 2026 15:23:57 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test:=20=EB=AA=A8=EC=A7=91=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20=EC=83=81=EC=84=B8=EC=A1=B0=ED=9A=8C=C2=B7=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BEFORE_OPEN 상태 상세조회 404, 공개 후 조회 성공 담당자 수정 성공/권한 없는 회원 수정 실패 시나리오 검증 --- .../BoothRecruitmentControllerTest.java | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/BE/src/test/java/com/min/edu/recruitment/controller/BoothRecruitmentControllerTest.java b/BE/src/test/java/com/min/edu/recruitment/controller/BoothRecruitmentControllerTest.java index 29cf1661..a8f0b9f6 100644 --- a/BE/src/test/java/com/min/edu/recruitment/controller/BoothRecruitmentControllerTest.java +++ b/BE/src/test/java/com/min/edu/recruitment/controller/BoothRecruitmentControllerTest.java @@ -212,6 +212,82 @@ void setUp() { .andExpect(jsonPath("$.data.status").value("COMPLETED")); } + @Test + void BEFORE_OPEN_상태의_상세조회는_404이고_공개된_후에는_조회된다() throws Exception { + String createResponse = mockMvc.perform(post("/events/{eventId}/booth-recruitment", eventId) + .header("Authorization", "Bearer " + eventManagerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(createRequestJson())) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + long recruitmentId = objectMapper.readTree(createResponse).path("data").path("id").asLong(); + + mockMvc.perform(get("/booth-recruitments/{recruitmentId}", recruitmentId)) + .andExpect(status().isNotFound()); + + mockMvc.perform(post("/events/{eventId}/booth-recruitment/closure", eventId) + .header("Authorization", "Bearer " + eventManagerToken)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/booth-recruitments/{recruitmentId}", recruitmentId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(recruitmentId)) + .andExpect(jsonPath("$.data.status").value("CLOSED")); + } + + @Test + void 담당자는_모집공고를_수정할_수_있다() throws Exception { + mockMvc.perform(post("/events/{eventId}/booth-recruitment", eventId) + .header("Authorization", "Bearer " + eventManagerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(createRequestJson())) + .andExpect(status().isOk()); + + OffsetDateTime newStart = OffsetDateTime.now().plusDays(2); + OffsetDateTime newEnd = OffsetDateTime.now().plusDays(20); + String updateJson = objectMapper.writeValueAsString(new java.util.LinkedHashMap() {{ + put("title", "수정된 모집 공고"); + put("recruitmentStartAt", newStart.toString()); + put("recruitmentEndAt", newEnd.toString()); + put("participantTarget", "제조업 기업"); + put("contactName", "김수정"); + put("contactEmail", "updated@example.com"); + put("contactPhone", "010-9999-8888"); + }}); + + mockMvc.perform(patch("/events/{eventId}/booth-recruitment", eventId) + .header("Authorization", "Bearer " + eventManagerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(updateJson)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("수정된 모집 공고")) + .andExpect(jsonPath("$.data.contactName").value("김수정")); + + mockMvc.perform(get("/events/{eventId}/booth-recruitment/management", eventId) + .header("Authorization", "Bearer " + eventManagerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("수정된 모집 공고")); + } + + @Test + void 담당자가_아니면_수정할_수_없다() throws Exception { + mockMvc.perform(post("/events/{eventId}/booth-recruitment", eventId) + .header("Authorization", "Bearer " + eventManagerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(createRequestJson())) + .andExpect(status().isOk()); + + mockMvc.perform(patch("/events/{eventId}/booth-recruitment", eventId) + .header("Authorization", "Bearer " + outsiderToken) + .contentType(MediaType.APPLICATION_JSON) + .content(createRequestJson())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("COMMON_403")); + } + private String createRequestJson() throws Exception { OffsetDateTime start = OffsetDateTime.now().plusDays(1); OffsetDateTime end = OffsetDateTime.now().plusDays(10); From 768dda6aadf21a2ef1b765abf9628cb92a23f547 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 3 Aug 2026 15:35:56 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EB=AA=A8=EC=A7=91=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=20API=20=ED=99=95=EC=9D=B8=EC=9A=A9=20=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recruitment API 7개(REC-API-001~007)를 실제로 호출해 동작을 확인하는 개발용 화면 공개 목록/상세, 행사별 관리(조회·생성·수정·조기마감·완료처리) 지원 FE dev 서버 실행용 .claude/launch.json 추가 --- .claude/launch.json | 11 + FE/src/api/recruitmentApi.js | 59 +++++ FE/src/main.jsx | 2 + FE/src/pages/RecruitmentCheck.jsx | 376 ++++++++++++++++++++++++++++++ 4 files changed, 448 insertions(+) create mode 100644 .claude/launch.json create mode 100644 FE/src/api/recruitmentApi.js create mode 100644 FE/src/pages/RecruitmentCheck.jsx diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..1ee1a87f --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "fe-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "FE", "run", "dev"], + "port": 5173 + } + ] +} diff --git a/FE/src/api/recruitmentApi.js b/FE/src/api/recruitmentApi.js new file mode 100644 index 00000000..eb620928 --- /dev/null +++ b/FE/src/api/recruitmentApi.js @@ -0,0 +1,59 @@ +import { apiRequest } from "./apiClient.js"; + +const listPublicRecruitments = async (status) => { + const query = status ? `?status=${status}` : ""; + const response = await apiRequest(`/booth-recruitments${query}`); + return response.data; +}; + +const getPublicRecruitment = async (recruitmentId) => { + const response = await apiRequest(`/booth-recruitments/${recruitmentId}`); + return response.data; +}; + +const getManagementRecruitment = async (eventId) => { + const response = await apiRequest(`/events/${eventId}/booth-recruitment/management`); + return response.data; +}; + +const createRecruitment = async (eventId, payload) => { + const response = await apiRequest(`/events/${eventId}/booth-recruitment`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + return response.data; +}; + +const updateRecruitment = async (eventId, payload) => { + const response = await apiRequest(`/events/${eventId}/booth-recruitment`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + return response.data; +}; + +const closeRecruitment = async (eventId) => { + const response = await apiRequest(`/events/${eventId}/booth-recruitment/closure`, { + method: "POST", + }); + return response.data; +}; + +const completeRecruitment = async (eventId) => { + const response = await apiRequest(`/events/${eventId}/booth-recruitment/completion`, { + method: "POST", + }); + return response.data; +}; + +export { + listPublicRecruitments, + getPublicRecruitment, + getManagementRecruitment, + createRecruitment, + updateRecruitment, + closeRecruitment, + completeRecruitment, +}; diff --git a/FE/src/main.jsx b/FE/src/main.jsx index c9ec0d2e..44fb0f4d 100644 --- a/FE/src/main.jsx +++ b/FE/src/main.jsx @@ -7,6 +7,7 @@ import Home from "./pages/Home.jsx"; import Login from "./pages/Login.jsx"; import EventOngoing from "./pages/EventOngoing.jsx"; import EventRecruiting from "./pages/EventRecruiting.jsx"; +import RecruitmentCheck from "./pages/RecruitmentCheck.jsx"; import BoothDetail from "./pages/BoothDetail.jsx"; import BoothApply from "./pages/BoothApply.jsx"; import MyPage from "./pages/MyPage.jsx"; @@ -20,6 +21,7 @@ const router = createBrowserRouter([ { path: "/login", element: }, { path: "/event-ongoing", element: }, { path: "/event-recruiting", element: }, + { path: "/recruitment-check", element: }, { path: "/booth-detail", element: }, { path: "/booth-apply", element: }, { diff --git a/FE/src/pages/RecruitmentCheck.jsx b/FE/src/pages/RecruitmentCheck.jsx new file mode 100644 index 00000000..4c13fb4f --- /dev/null +++ b/FE/src/pages/RecruitmentCheck.jsx @@ -0,0 +1,376 @@ +import { useCallback, useEffect, useState } from "react"; +import TopNav from "../components/TopNav.jsx"; +import Icon from "../components/Icon.jsx"; +import useAuth from "../hooks/useAuth.js"; +import { ApiError } from "../api/apiClient.js"; +import { + listPublicRecruitments, + getPublicRecruitment, + getManagementRecruitment, + createRecruitment, + updateRecruitment, + closeRecruitment, + completeRecruitment, +} from "../api/recruitmentApi.js"; + +const STATUS_LABEL = { + BEFORE_OPEN: "모집 준비", + OPEN: "모집 중", + CLOSED: "모집 마감", + COMPLETED: "모집 완료", +}; + +const EMPTY_FORM = { + title: "", + recruitmentStartAt: "", + recruitmentEndAt: "", + participantTarget: "", + qualification: "", + selectionMethod: "", + contactName: "", + contactEmail: "", + contactPhone: "", + notice: "", +}; + +const toIsoOffset = (datetimeLocalValue) => { + if (!datetimeLocalValue) return ""; + return new Date(datetimeLocalValue).toISOString(); +}; + +const toDatetimeLocal = (isoValue) => { + if (!isoValue) return ""; + return new Date(isoValue).toISOString().slice(0, 16); +}; + +export default function RecruitmentCheck() { + const { isAuthenticated, member } = useAuth(); + + const [publicList, setPublicList] = useState([]); + const [publicListError, setPublicListError] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + + const [detailId, setDetailId] = useState(""); + const [detailResult, setDetailResult] = useState(null); + const [detailError, setDetailError] = useState(""); + + const [eventId, setEventId] = useState(""); + const [managementResult, setManagementResult] = useState(null); + const [managementError, setManagementError] = useState(""); + const [form, setForm] = useState(EMPTY_FORM); + const [actionMessage, setActionMessage] = useState(""); + + const loadPublicList = useCallback(async () => { + setPublicListError(""); + try { + const data = await listPublicRecruitments(statusFilter || undefined); + setPublicList(data); + } catch (error) { + setPublicListError(error instanceof ApiError ? error.message : "목록 조회에 실패했습니다."); + } + }, [statusFilter]); + + useEffect(() => { + loadPublicList(); + }, [loadPublicList]); + + const handleDetailLookup = async () => { + setDetailError(""); + setDetailResult(null); + if (!detailId) return; + + try { + const data = await getPublicRecruitment(detailId); + setDetailResult(data); + } catch (error) { + setDetailError(error instanceof ApiError ? `${error.code}: ${error.message}` : "상세 조회에 실패했습니다."); + } + }; + + const fillFormFromResult = (data) => { + setForm({ + title: data.title ?? "", + recruitmentStartAt: toDatetimeLocal(data.recruitmentStartAt), + recruitmentEndAt: toDatetimeLocal(data.recruitmentEndAt), + participantTarget: data.participantTarget ?? "", + qualification: data.qualification ?? "", + selectionMethod: data.selectionMethod ?? "", + contactName: data.contactName ?? "", + contactEmail: data.contactEmail ?? "", + contactPhone: data.contactPhone ?? "", + notice: data.notice ?? "", + }); + }; + + const handleManagementLookup = async () => { + setManagementError(""); + setActionMessage(""); + if (!eventId) return; + + try { + const data = await getManagementRecruitment(eventId); + setManagementResult(data); + fillFormFromResult(data); + } catch (error) { + setManagementResult(null); + if (error instanceof ApiError && error.status === 404) { + setManagementError("이 행사에는 아직 모집 공고가 없습니다. 아래 폼으로 생성해 보세요."); + } else { + setManagementError(error instanceof ApiError ? `${error.code}: ${error.message}` : "조회에 실패했습니다."); + } + } + }; + + const buildPayload = () => ({ + ...form, + recruitmentStartAt: toIsoOffset(form.recruitmentStartAt), + recruitmentEndAt: toIsoOffset(form.recruitmentEndAt), + }); + + const runAction = async (actionFn, successMessage) => { + setManagementError(""); + setActionMessage(""); + + try { + const data = await actionFn(); + setManagementResult(data); + setActionMessage(successMessage); + loadPublicList(); + } catch (error) { + setManagementError(error instanceof ApiError ? `${error.code}: ${error.message}` : "요청에 실패했습니다."); + } + }; + + const handleCreate = () => runAction(() => createRecruitment(eventId, buildPayload()), "생성되었습니다."); + const handleUpdate = () => runAction(() => updateRecruitment(eventId, buildPayload()), "수정되었습니다."); + const handleClose = () => runAction(() => closeRecruitment(eventId), "조기 마감 처리되었습니다."); + const handleComplete = () => runAction(() => completeRecruitment(eventId), "완료 처리되었습니다."); + + return ( +
+ + +
+
+

모집 공고 API 확인용 페이지

+

+ recruitment API(REC-API-001~007)를 실제로 호출해서 동작을 확인하는 개발용 화면입니다. +

+
+ + {/* 공개 목록 */} +
+
+

공개 모집 공고 목록

+
+ + +
+
+ + {publicListError &&

{publicListError}

} + + {publicList.length === 0 ? ( +

+ 공개된 모집 공고가 없습니다. (BEFORE_OPEN 상태는 목록에서 숨겨집니다) +

+ ) : ( +
+ {publicList.map((r) => ( +
+
+

{r.title}

+

eventId {r.eventId} · id {r.id}

+
+
+ + {STATUS_LABEL[r.status] ?? r.status} + + +
+
+ ))} +
+ )} +
+ + {/* 공개 상세 */} +
+

모집 공고 상세 조회

+
+ setDetailId(e.target.value)} + className="flex-1 border border-hairline rounded-lg px-md py-sm" + /> + +
+ {detailError &&

{detailError}

} + {detailResult && ( +
+              {JSON.stringify(detailResult, null, 2)}
+            
+ )} +
+ + {/* 관리 (인증 필요) */} +
+

행사별 모집 공고 관리 (EVENT_MANAGER 권한 필요)

+ + {!isAuthenticated ? ( +

로그인이 필요합니다. 상단 로그인 버튼을 눌러주세요.

+ ) : ( +

{member.nickname}님으로 로그인됨 (memberId {member.memberId})

+ )} + +
+ setEventId(e.target.value)} + className="flex-1 border border-hairline rounded-lg px-md py-sm" + /> + +
+ + {managementError &&

{managementError}

} + {actionMessage &&

{actionMessage}

} + + {managementResult && ( +
+ 현재 상태: + + {STATUS_LABEL[managementResult.status] ?? managementResult.status} + + + +
+ )} + +
+ setForm({ ...form, title: e.target.value })} + className="border border-hairline rounded-lg px-md py-sm md:col-span-2" + /> + + setForm({ ...form, recruitmentStartAt: e.target.value })} + className="border border-hairline rounded-lg px-md py-sm" + /> +
+ + setForm({ ...form, recruitmentEndAt: e.target.value })} + className="border border-hairline rounded-lg px-md py-sm" + /> +
+ setForm({ ...form, participantTarget: e.target.value })} + className="border border-hairline rounded-lg px-md py-sm md:col-span-2" + /> + setForm({ ...form, qualification: e.target.value })} + className="border border-hairline rounded-lg px-md py-sm" + /> + setForm({ ...form, selectionMethod: e.target.value })} + className="border border-hairline rounded-lg px-md py-sm" + /> + setForm({ ...form, contactName: e.target.value })} + className="border border-hairline rounded-lg px-md py-sm" + /> + setForm({ ...form, contactEmail: e.target.value })} + className="border border-hairline rounded-lg px-md py-sm" + /> + setForm({ ...form, contactPhone: e.target.value })} + className="border border-hairline rounded-lg px-md py-sm md:col-span-2" + /> +