Skip to content
Merged
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
@@ -1,31 +1,22 @@
package com.studyclub.api.application;

import com.studyclub.api.application.StudyApplicationFormRequests.StudyApplicationFormRequest;
import com.studyclub.api.application.StudyApplicationFormResponses.StudyApplicationFormResponse;
import com.studyclub.api.auth.security.RequireCaptainOrNavigator;
import com.studyclub.api.auth.security.RequireOnboarding;
import com.studyclub.common.error.BusinessException;
import com.studyclub.common.error.ErrorCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;

/**
* 사용자 사이트의 신청 폼 — 크루가 조회하고, 캡틴·네비게이터가 저장한다.
* 사용자 사이트의 신청 폼 — 크루가 조회한다.
*
* <p>백오피스 화면은 {@link AdminApplicationFormController}({@code /api/admin})를 쓰고 <b>캡틴만</b> 통과한다. 같은
* 기능이지만 관객이 달라 경로와 권한을 나눴다 — POL-0001 · api/endpoint-convention.md
* <p>저장(PUT)은 백오피스 {@link AdminApplicationFormController}({@code /api/admin})에서만 가능하고 캡틴만 통과한다.
* specs/authz-guards/spec.md B절 후속 — 네비게이터는 신청 폼을 고치지 않는다.
*/
@Tag(name = "스터디 신청 폼", description = "크루가 조회하고, 캡틴·네비게이터가 저장한다")
@Tag(name = "스터디 신청 폼", description = "크루가 조회한다")
@RestController
@RequestMapping("/api/studies/{studyId}/application-form")
public class StudyApplicationFormController {
Expand All @@ -50,34 +41,6 @@ public StudyApplicationFormResponse getForm(
return studyApplicationFormService.getForm(studyId, optionalAccountId(authentication));
}

@Operation(
summary = "스터디 신청 폼 저장 (사용자 사이트)",
description =
"""
캡틴 또는 그 스터디의 네비게이터가 추가 질문을 통째로 교체합니다.
백오피스에서 저장하는 경로는 PUT /api/admin/studies/{studyId}/application-form 이고,
그쪽은 캡틴만 통과합니다 (POL-0001).
이미 신청서가 있거나 모집이 시작됐으면 CONFLICT입니다.""")
@SecurityRequirement(name = "bearerAuth")
@RequireOnboarding
@RequireCaptainOrNavigator
@PutMapping
public StudyApplicationFormResponse replaceForm(
@Parameter(description = "신청 폼을 저장할 스터디 ID", example = "1") @PathVariable Long studyId,
@Valid @RequestBody StudyApplicationFormRequest request,
Authentication authentication) {
return studyApplicationFormService.replaceFormFromSite(
studyId, authenticatedAccountId(authentication), request);
}

private Long authenticatedAccountId(Authentication authentication) {
Long accountId = optionalAccountId(authentication);
if (accountId == null) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return accountId;
}

private Long optionalAccountId(Authentication authentication) {
if (authentication == null || !(authentication.getPrincipal() instanceof Long accountId)) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,6 @@ public StudyApplicationFormResponse getForm(Long studyId, Long accountId) {
return toResponse(study);
}

/** 사용자 사이트 저장 — 권한은 {@code @RequireCaptainOrNavigator}. */
@Transactional
public StudyApplicationFormResponse replaceFormFromSite(
Long studyId, Long accountId, StudyApplicationFormRequest request) {
return replace(studyId, request);
}

/** 백오피스 저장 — 권한은 {@code @RequireAdmin}. */
@Transactional
public StudyApplicationFormResponse replaceFormFromBackOffice(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,29 +106,16 @@ void publicFormIsReadableWithoutToken() {
}

@Test
@DisplayName("성공 - 네비게이터는 사용자 사이트에서 신청 폼을 저장하고 정규화된 폼을 다시 받는다")
void navigatorReplacesFormFromSite() {
@DisplayName("실패 - 사용자 사이트 PUT은 삭제되어 존재하지 않는다 (4xx — 메서드 불일치)")
void sitePutIsRemoved() {
var response =
rest.exchange(
"/api/studies/" + EDITABLE_STUDY_ID + "/application-form",
HttpMethod.PUT,
authenticatedJsonRequest(LEADER_ID, validRequest()),
Map.class);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("title", "새 신청 폼");
assertThat(response.getBody()).containsEntry("description", "마크다운 **원문**");
List<?> questions = (List<?>) response.getBody().get("questions");
assertThat(questions).hasSize(1);
@SuppressWarnings("unchecked")
Map<String, Object> question = (Map<String, Object>) questions.get(0);
assertThat(question).containsEntry("id", "motivation");
assertThat(question).containsEntry("label", "지원 동기");
assertThat(question).containsEntry("type", "TEXTAREA");
assertThat(question).containsEntry("required", true);
assertThat(question).containsEntry("placeholder", "자유롭게 적어주세요");
assertThat(question).containsEntry("options", null);
assertThat(question).containsEntry("allowOther", null);
assertThat(response.getStatusCode().is4xxClientError()).isTrue();
}

@Test
Expand All @@ -143,11 +130,11 @@ void draftFormRequiresAuthentication() {
}

@Test
@DisplayName("실패 - 그 스터디의 네비게이터도 캡틴도 아니면 저장할 수 없다")
@DisplayName("실패 - 일반 멤버는 백오피스로 신청 폼을 저장할 수 없다")
void rejectsOutsider() {
var response =
rest.exchange(
"/api/studies/" + EDITABLE_STUDY_ID + "/application-form",
"/api/admin/studies/" + EDITABLE_STUDY_ID + "/application-form",
HttpMethod.PUT,
authenticatedJsonRequest(MEMBER_ID, validRequest()),
Map.class);
Expand Down Expand Up @@ -189,9 +176,9 @@ void navigatorCannotUseBackOffice() {
void rejectsLockedForm() {
var response =
rest.exchange(
"/api/studies/" + LOCKED_STUDY_ID + "/application-form",
"/api/admin/studies/" + LOCKED_STUDY_ID + "/application-form",
HttpMethod.PUT,
authenticatedJsonRequest(LEADER_ID, validRequest()),
authenticatedJsonRequest(CAPTAIN_ID, validRequest()),
Map.class);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
Expand Down Expand Up @@ -221,9 +208,9 @@ void rejectsReservedQuestionId() {

var response =
rest.exchange(
"/api/studies/" + EDITABLE_STUDY_ID + "/application-form",
"/api/admin/studies/" + EDITABLE_STUDY_ID + "/application-form",
HttpMethod.PUT,
authenticatedJsonRequest(LEADER_ID, request),
authenticatedJsonRequest(CAPTAIN_ID, request),
Map.class);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
Expand Down
6 changes: 3 additions & 3 deletions specs/authz-guards/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@

- path `studyId` 필수.
- 통과: 그 스터디의 **어느 `STUDY_GROUP`이든** 네비게이터인 경우.
- 대상: 스터디 정보 수정, 신청 폼 저장 등 — 분반에 묶이지 않는 API.
- 대상: 스터디 정보 수정 등 — 분반에 묶이지 않는 API. (신청 폼 저장 사용자 사이트 경로는 2026-11-07 삭제됨)

#### 분반 단위 (출석 등)

Expand Down Expand Up @@ -122,12 +122,12 @@
| 화면 | Method | Path | 범위 | 지금 | 이번 |
|------|--------|------|------|------|------|
| 스터디 수정 | PATCH | `/api/studies/{studyId}` | **스터디** | `assertCaptainOrNavigator` | **스터디 단위** 어노테이션, Guard 호출 **제거** |
| 신청 폼 저장 | PUT | `/api/studies/{studyId}/application-form` | **스터디** | 동일 | **스터디 단위** + `@RequireOnboarding`, Guard 호출 **제거** |
| 신청 폼 저장 | PUT | `/api/studies/{studyId}/application-form` | **스터디** | 동일 | **삭제 완료 (2026-11-07)** — 아래 각주 참고 |
| 신청 폼 조회 | GET | 동일 | — | 공개/편집 분기 | **변경 없음** (어노테이션 **금지** — [위 주의](#신청-폼-get--공개-분기-주의)) |
| 출석 upsert | POST | `/api/studies/{studyId}/attendances` | **분반** | LEADER(스터디 전체, 캡틴 미포함) | **분반 단위** + `@RequireOnboarding`, 인라인 검사 **제거**. 캡틴 통과 · **타 분반 네비게이터 403** |
| 출석 명부 | GET | 동일 (`studyGroupId` query) | **분반** | 인증만 | **분반 단위** + `@RequireOnboarding` — 캡틴 또는 **그 `studyGroupId` 네비게이터만** |

> **후속 (2026-10-04 기획 확정)** — 네비게이터는 신청 폼을 고치지 못하고 백오피스에도 들어오지 못한다. 그래서 위 `PUT /api/studies/{studyId}/application-form`(네비게이터용 사용자 사이트 경로)은 폐기 예정이고, 백오피스 `PUT /api/admin/studies/{studyId}/application-form` 은 `@RequireAdmin` 에 더해 **이 기수 담당 캡틴**(스터디를 생성한 캡틴 — `STUDY.CREATED_BY = 나`, NULL 이면 캡틴 누구나)만 통과시켜야 한다. 사용자 사이트 `PUT` 은 지운다 (2026-10-07). 백오피스 신청 폼 조회·신청 결과 조회는 지금처럼 캡틴 누구나. 근거: [POL-0001](../../01-planning/_registry/policies/POL-0001-roles.md) · [신청 스펙](../study-application/spec.md)
> **완료 (2026-10-04 기획 확정 → 2026-11-07 삭제)** — 네비게이터는 신청 폼을 고치지 못한다. `PUT /api/studies/{studyId}/application-form`(사용자 사이트 경로) 삭제 완료. 백오피스 `PUT /api/admin/studies/{studyId}/application-form` 은 `@RequireAdmin`(캡틴만) 유지. 근거: [POL-0001](../../01-planning/_registry/policies/POL-0001-roles.md)

### C. 회원 전용 · 온보딩

Expand Down
Loading