Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import moadong.club.entity.ClubApplication;
import moadong.club.payload.request.ClubApplicantEditRequest;
import moadong.club.payload.request.ClubApplicationCreateRequest;
import moadong.club.payload.request.ClubApplicationEditRequest;
import moadong.club.payload.request.ClubApplyRequest;
import moadong.club.payload.response.ClubApplicationResponse;
import moadong.club.service.ClubApplyService;
import moadong.global.payload.Response;
import moadong.user.annotation.CurrentUser;
Expand All @@ -18,8 +17,6 @@
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/club/{clubId}")
@AllArgsConstructor
Expand Down Expand Up @@ -73,4 +70,33 @@ public ResponseEntity<?> getApplyInfo(@PathVariable String clubId,
return Response.ok(clubApplyService.getClubApplyInfo(clubId, user));
}

@PutMapping("/apply/{appId}")
@Operation(summary = "지원서 변경",
description = "클럽 자원자의 지원서 정보를 수정합니다.<br>"
+ "appId - 지원서 아이디"
)
@PreAuthorize("isAuthenticated()")
@SecurityRequirement(name = "BearerAuth")
public ResponseEntity<?> editApplicantDetail(@PathVariable String clubId,
@PathVariable String appId,
@RequestBody @Validated ClubApplicantEditRequest request,
@CurrentUser CustomUserDetails user) {
clubApplyService.editApplicantDetail(clubId, appId, request, user);
return Response.ok("success edit applicant");
}

@DeleteMapping("/apply/{appId}")
@Operation(summary = "지원서 삭제",
description = "클럽 자원자의 지원서를 삭제합니다.<br>"
+ "appId - 지원서 아이디"
)
@PreAuthorize("isAuthenticated()")
@SecurityRequirement(name = "BearerAuth")
public ResponseEntity<?> removeApplicant(@PathVariable String clubId,
@PathVariable String appId,
@CurrentUser CustomUserDetails user) {
clubApplyService.deleteApplicant(clubId, appId, user);
return Response.ok("success delete applicant");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,17 @@ public class ClubApplication {
@Builder.Default
ApplicationStatus status = ApplicationStatus.SUBMITTED;

@Builder.Default
private String memo = "";

@Builder.Default
private List<ClubQuestionAnswer> answers = new ArrayList<>();

@Builder.Default
LocalDateTime createdAt = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime();

public void updateDetail(String memo, ApplicationStatus status) {
this.memo = memo;
this.status = status;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,17 @@
import moadong.global.exception.RestApiException;
import moadong.global.util.AESCipher;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Builder
@Slf4j
public record ClubApplicantsResult(
String questionId,
String id,
ApplicationStatus status,
List<ClubQuestionAnswer> answers
List<ClubQuestionAnswer> answers,
LocalDateTime createdAt
) {
public static ClubApplicantsResult of(ClubApplication application, AESCipher cipher) {
List<ClubQuestionAnswer> decryptedAnswers = new ArrayList<>();
Expand All @@ -35,9 +37,10 @@ public static ClubApplicantsResult of(ClubApplication application, AESCipher cip
}

return ClubApplicantsResult.builder()
.questionId(application.getQuestionId())
.id(application.getId())
.status(application.getStatus())
.answers(decryptedAnswers)
.createdAt(application.getCreatedAt())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package moadong.club.payload.request;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import moadong.club.enums.ApplicationStatus;

public record ClubApplicantEditRequest(
@NotNull
@Size(max = 500)
String memo,

@NotNull
ApplicationStatus status
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
import org.springframework.data.mongodb.repository.Query;

import java.util.List;
import java.util.Optional;

public interface ClubApplicationRepository extends MongoRepository<ClubApplication, String> {
@Query("{ 'questionId': ?0, 'status': { $exists: true, $ne: 'DRAFT' } }")
List<ClubApplication> findAllByQuestionId(String questionId);

Optional<ClubApplication> findByIdAndQuestionId(String id, String questionId);
}
34 changes: 34 additions & 0 deletions backend/src/main/java/moadong/club/service/ClubApplyService.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package moadong.club.service;

import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import moadong.club.entity.*;
import moadong.club.enums.ClubApplicationQuestionType;
import moadong.club.payload.dto.ClubApplicantsResult;
import moadong.club.payload.request.ClubApplicantEditRequest;
import moadong.club.payload.request.ClubApplicationCreateRequest;
import moadong.club.payload.request.ClubApplicationEditRequest;
import moadong.club.payload.request.ClubApplyRequest;
Expand Down Expand Up @@ -123,6 +125,38 @@ public ClubApplyInfoResponse getClubApplyInfo(String clubId, CustomUserDetails u
.build();
}

@Transactional
public void editApplicantDetail(String clubId, String appId, ClubApplicantEditRequest request, CustomUserDetails user) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));

if (!user.getId().equals(club.getUserId())) {
throw new RestApiException(ErrorCode.USER_UNAUTHORIZED);
}

ClubApplication application = clubApplicationRepository.findByIdAndQuestionId(appId, clubId)
.orElseThrow(() -> new RestApiException(ErrorCode.APPLICANT_NOT_FOUND));

application.updateDetail(request.memo(), request.status());

clubApplicationRepository.save(application);
}
Comment on lines +128 to +143
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

지원자 수정: 상태 전이 검증·동시성 보호(@Version)·중복 로직 추출 제안

  • 권한 검증/조회 흐름은 적절합니다.
  • 상태 전이 검증: 도메인 규칙(예: SCREENING → SCREENING_PASSED → …) 유효성 체크가 필요합니다. 서비스 레벨에서 허용되지 않는 전이를 차단하세요.
  • 동시성: 다중 관리자가 동시에 수정 시 마지막 저장이 덮어쓸 수 있습니다. ClubApplication 엔티티에 @Version(낙관적 락)을 도입하는 것을 권장합니다.
  • 중복 제거: 클럽 조회+권한검사, 지원서 조회 로직이 deleteApplicant와 반복됩니다. 헬퍼 메서드로 추출하면 가독성과 재사용성이 좋아집니다.
  • 저장 호출: JPA 영속 상태라면 dirty checking으로 flush 됩니다. 명시적 save 유지/제거는 팀 컨벤션에 맞추세요.

중복 제거용 최소 변경 diff:

-        Club club = clubRepository.findById(clubId)
-                .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
-
-        if (!user.getId().equals(club.getUserId())) {
-            throw new RestApiException(ErrorCode.USER_UNAUTHORIZED);
-        }
+        Club club = getAuthorizedClubOrThrow(clubId, user);

-        ClubApplication application = clubApplicationRepository.findByIdAndQuestionId(appId, clubId)
-                .orElseThrow(() -> new RestApiException(ErrorCode.APPLICANT_NOT_FOUND));
+        ClubApplication application = getApplicantOrThrow(appId, clubId);

헬퍼 메서드(파일 내 적절한 위치에 추가):

private Club getAuthorizedClubOrThrow(String clubId, CustomUserDetails user) {
    Club club = clubRepository.findById(clubId)
        .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
    if (!user.getId().equals(club.getUserId())) {
        throw new RestApiException(ErrorCode.USER_UNAUTHORIZED);
    }
    return club;
}

private ClubApplication getApplicantOrThrow(String appId, String clubId) {
    return clubApplicationRepository.findByIdAndQuestionId(appId, clubId)
        .orElseThrow(() -> new RestApiException(ErrorCode.APPLICANT_NOT_FOUND));
}

상태/동시성 관련 확인 스크립트:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) ClubApplication에 @Version(낙관적 락) 존재 여부 확인
fd -t f 'ClubApplication.java' | xargs -I {} rg -n --context 2 '@Version|class ClubApplication' {}

# 2) ClubApplicantEditRequest의 검증 애노테이션 확인(메모/상태 Null 허용 여부)
fd -t f 'ClubApplicantEditRequest.java' | xargs -I {} sed -n '1,200p' {}

# 3) Repository 시그니처 및 의미 확인
fd -t f 'ClubApplicationRepository.java' | xargs -I {} sed -n '1,200p' {}
rg -n 'findByIdAndQuestionId\(' -A 3

Length of output: 2625


지원자 수정 작업에 아래 사항 보강 필요

  • 요청 DTO 검증 확인
    ClubApplicantEditRequestmemo(@NotNull, @SiZe(max=500))와 status(@NotNull)에 Bean Validation이 적용되어 있습니다.
  • 상태 전이 검증
    ClubApplication.updateDetail(...) 내부에 SCREENING → SCREENING_PASSED 등 도메인 전이 유효성을 체크하는 로직이 없어 서비스 레이어에서 불법 전이를 차단해야 합니다.
  • 동시성 보호(@Version)
    ClubApplication 엔티티에 @Version 필드가 없어 다중 관리자 동시 수정 시 데이터 손실 위험이 있습니다.
  • 중복 로직 추출
    editApplicantDetaildeleteApplicant에서 클럽 조회·권한 검증, 지원서 조회 로직이 반복됩니다. 헬퍼 메서드로 분리해 가독성과 재사용성을 높이세요.
  • 저장 호출 일관성
    MongoRepository는 변경 감지를 통해 저장하지만, 명시적 save(...) 호출 여부를 팀 컨벤션에 맞춰 통일하세요.

필요한 최소 변경 예시:

-        Club club = clubRepository.findById(clubId)
-                .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
-
-        if (!user.getId().equals(club.getUserId())) {
-            throw new RestApiException(ErrorCode.USER_UNAUTHORIZED);
-        }
+        Club club = getAuthorizedClubOrThrow(clubId, user);

-        ClubApplication application = clubApplicationRepository.findByIdAndQuestionId(appId, clubId)
-                .orElseThrow(() -> new RestApiException(ErrorCode.APPLICANT_NOT_FOUND));
+        ClubApplication application = getApplicantOrThrow(appId, clubId);

헬퍼 메서드 예시 (클래스 내 적절한 위치에 추가):

private Club getAuthorizedClubOrThrow(String clubId, CustomUserDetails user) {
    Club club = clubRepository.findById(clubId)
        .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
    if (!user.getId().equals(club.getUserId())) {
        throw new RestApiException(ErrorCode.USER_UNAUTHORIZED);
    }
    return club;
}

private ClubApplication getApplicantOrThrow(String appId, String clubId) {
    return clubApplicationRepository.findByIdAndQuestionId(appId, clubId)
        .orElseThrow(() -> new RestApiException(ErrorCode.APPLICANT_NOT_FOUND));
}

낙관적 락 적용을 위해 ClubApplication 엔티티에 다음 필드를 추가하세요:

@Version
private Long version;
🤖 Prompt for AI Agents
In backend/src/main/java/moadong/club/service/ClubApplyService.java around lines
128 to 143, enhance the editApplicantDetail method by first validating the
ClubApplicantEditRequest DTO using Bean Validation annotations already present
on memo and status fields. Add explicit state transition checks in the service
layer to prevent illegal transitions like SCREENING to SCREENING_PASSED before
calling updateDetail. Introduce an @Version field in the ClubApplication entity
to enable optimistic locking and prevent concurrent update conflicts. Extract
repeated logic for club retrieval and authorization as well as applicant
retrieval into private helper methods to improve code reuse and readability.
Finally, ensure consistent use of save calls on the repository according to team
conventions, either always calling save explicitly or relying on change
detection uniformly.


@Transactional
public void deleteApplicant(String clubId, String appId, CustomUserDetails user) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));

if (!user.getId().equals(club.getUserId())) {
throw new RestApiException(ErrorCode.USER_UNAUTHORIZED);
}

ClubApplication application = clubApplicationRepository.findByIdAndQuestionId(appId, clubId)
.orElseThrow(() -> new RestApiException(ErrorCode.APPLICANT_NOT_FOUND));

clubApplicationRepository.delete(application);
}
Comment on lines +145 to +158
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

지원자 삭제: 연관 엔티티 삭제 보장, 감사 로깅, 중복 로직 추출 제안

  • 연관 데이터(예: answers)가 있는 경우 FK 제약/고아 제거 설정이 필요합니다. @OneToMany(orphanRemoval = true, cascade = CascadeType.ALL) 등으로 삭제 일관성을 보장해 주세요.
  • 감사/감사추적 요구가 있다면 soft delete(상태 플래그)나 감사 로그 기록 고려를 권장합니다.
  • getAuthorizedClubOrThrow, getApplicantOrThrow로 중복 조회/검증 로직을 통합하면 유지보수성이 향상됩니다.

중복 제거용 최소 변경 diff:

-        Club club = clubRepository.findById(clubId)
-                .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
-
-        if (!user.getId().equals(club.getUserId())) {
-            throw new RestApiException(ErrorCode.USER_UNAUTHORIZED);
-        }
+        Club club = getAuthorizedClubOrThrow(clubId, user);

-        ClubApplication application = clubApplicationRepository.findByIdAndQuestionId(appId, clubId)
-                .orElseThrow(() -> new RestApiException(ErrorCode.APPLICANT_NOT_FOUND));
+        ClubApplication application = getApplicantOrThrow(appId, clubId);

연관 삭제 설정 점검 스크립트:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# answers 매핑에 orphanRemoval / cascade 설정 확인
fd -t f 'ClubApplication.java' | xargs -I {} rg -n --context 5 'answers|@OneToMany|@ElementCollection|cascade|orphanRemoval' {}

Length of output: 509


중복 로직 추출 및 연관 엔티티 삭제 설정 적용 제안

ClubApplication 삭제 시 답변(answers)이 JPA 매핑 없이 일반 컬렉션으로만 선언되어 있어, 연관 엔티티가 영속성 컨텍스트에서 함께 삭제되지 않습니다. 또한 서비스 레이어의 조회·검증 로직도 중복되고 있어 아래 사항을 적용해 주세요.

• ClubApplication.answers 필드에 JPA 매핑 추가

  • @onetomany(mappedBy = "application", cascade = CascadeType.ALL, orphanRemoval = true) 등으로 답변 삭제 일관성 보장
    • ClubApplyService 내 중복 조회·검증 로직 추출
  • getAuthorizedClubOrThrow(clubId, user)
  • getApplicantOrThrow(appId, clubId)
    • 감사 트래킹(soft delete or audit log) 필요 시 별도 구현 고려

예시 최소 변경 diff:

--- a/backend/src/main/java/moadong/club/entity/ClubApplication.java
+++ b/backend/src/main/java/moadong/club/entity/ClubApplication.java
@@
-    @Builder.Default
-    private List<ClubQuestionAnswer> answers = new ArrayList<>();
+    @OneToMany(
+        mappedBy = "application",
+        cascade = CascadeType.ALL,
+        orphanRemoval = true
+    )
+    @Builder.Default
+    private List<ClubQuestionAnswer> answers = new ArrayList<>();
--- a/backend/src/main/java/moadong/club/service/ClubApplyService.java
+++ b/backend/src/main/java/moadong/club/service/ClubApplyService.java
@@ public void deleteApplicant(String clubId, String appId, CustomUserDetails user) {
-        Club club = clubRepository.findById(clubId)
-                .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND));
-
-        if (!user.getId().equals(club.getUserId())) {
-            throw new RestApiException(ErrorCode.USER_UNAUTHORIZED);
-        }
+        Club club = getAuthorizedClubOrThrow(clubId, user);
@@
-        ClubApplication application = clubApplicationRepository.findByIdAndQuestionId(appId, clubId)
-                .orElseThrow(() -> new RestApiException(ErrorCode.APPLICANT_NOT_FOUND));
+        ClubApplication application = getApplicantOrThrow(appId, clubId);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In backend/src/main/java/moadong/club/service/ClubApplyService.java around lines
145 to 158, the deleteApplicant method has duplicated club and applicant
retrieval and authorization logic, and the ClubApplication entity's answers
collection lacks JPA cascade settings causing orphaned data. Refactor by
extracting the club retrieval and authorization into a getAuthorizedClubOrThrow
method and the applicant retrieval into getApplicantOrThrow method to remove
duplication. Also, update the ClubApplication.answers field to use
@OneToMany(mappedBy = "application", cascade = CascadeType.ALL, orphanRemoval =
true) to ensure related answers are deleted automatically with the application.
Consider adding audit or soft delete separately if needed.


private void validateAnswers(List<ClubApplyRequest.Answer> answers, ClubQuestion clubQuestion) {
// 미리 질문과 응답 id 만들어두기
Map<Long, ClubApplicationQuestion> questionMap = clubQuestion.getQuestions().stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ public enum ErrorCode {
QUESTION_NOT_FOUND(HttpStatus.NOT_FOUND, "800-4", "존재하지 않은 질문입니다."),
REQUIRED_QUESTION_MISSING(HttpStatus.BAD_REQUEST, "800-5", "필수 응답 질문이 누락되었습니다."),

AES_CIPHER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "900-1", "암호화 중 오류가 발생했습니다.")
AES_CIPHER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "900-1", "암호화 중 오류가 발생했습니다."),
APPLICANT_NOT_FOUND(HttpStatus.NOT_FOUND, "900-2", "지원서가 존재하지 않습니다."),
;
Comment on lines +42 to 44
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

오류 코드 중복 정의

APPLICATION_NOT_FOUND(800-1)와 APPLICANT_NOT_FOUND(900-2)가 동일한 메시지와 의미를 갖고 있어 혼동을 유발합니다.
하나의 코드로 통합하거나, 명확히 다른 도메인을 나타내도록 메시지·코드를 재조정해 주세요.

🤖 Prompt for AI Agents
In backend/src/main/java/moadong/global/exception/ErrorCode.java around lines 42
to 44, the error codes APPLICATION_NOT_FOUND (800-1) and APPLICANT_NOT_FOUND
(900-2) have overlapping meanings and messages, causing confusion. Review these
two error codes and either merge them into a single unified error code or
differentiate their messages and codes clearly to reflect distinct domains or
error contexts. Update the enum definitions accordingly to avoid duplication and
ambiguity.


private final HttpStatus httpStatus;
Expand Down
Loading