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
Expand Up @@ -8,7 +8,46 @@
@Getter
@RequiredArgsConstructor
public enum GroupErrorCode implements ErrorCode {
NO_PERMISSION_TO_REJECT_JOIN(HttpStatus.FORBIDDEN,
"모임: 참여 거절 권한이 없습니다. 모임 ID: %s 회원 ID: %s"
),
CANNOT_APPROVE_SELF(HttpStatus.BAD_REQUEST,
"모임: 자기 자신을 승인할 수 없습니다. 모임 ID: %s 회원 ID: %s"
),
CANNOT_REJECT_SELF(HttpStatus.BAD_REQUEST,
"모임: 자기 자신을 거절할 수 없습니다. 모임 ID: %s 회원 ID: %s"
),
GROUP_USER_STATUS_NOT_ALLOWED_TO_APPROVE(HttpStatus.BAD_REQUEST,
"모임: 승인 처리는 PENDING 상태에서만 가능합니다. 모임 ID: %s 회원 ID: %s 현재 상태: %s"
),

GROUP_USER_STATUS_NOT_ALLOWED_TO_REJECT(HttpStatus.BAD_REQUEST,
"모임: 거절 처리는 PENDING 상태에서만 가능합니다. 모임 ID: %s 회원 ID: %s 현재 상태: %s"
),
GROUP_JOIN_POLICY_NOT_APPROVAL_REQUIRED(HttpStatus.BAD_REQUEST,
"모임: 승인제 모임이 아니어서 승인/거절이 불가능합니다. 모임 ID: %s 참여 방식: %s"
),

GROUP_CANNOT_APPROVE_IN_STATUS(HttpStatus.BAD_REQUEST,
"모임: 현재 모임 상태에서는 승인 처리가 불가능합니다. 모임 ID: %s 모임 상태: %s"
),

GROUP_CANNOT_REJECT_IN_STATUS(HttpStatus.BAD_REQUEST,
"모임: 현재 모임 상태에서는 거절 처리가 불가능합니다. 모임 ID: %s 모임 상태: %s"
),

NO_PERMISSION_TO_APPROVE_JOIN(HttpStatus.FORBIDDEN,
"모임: 참여 승인/거절 권한이 없습니다. 모임 ID: %s 회원 ID: %s"
),

GROUP_USER_NOT_PENDING_STATUS(HttpStatus.BAD_REQUEST,
"모임: 승인/거절은 PENDING 상태에서만 가능합니다. 모임 ID: %s 회원 ID: %s 현재 상태: %s"
),

// 필요하면(선택): 이미 처리된 요청을 더 명확히 구분하고 싶을 때
GROUP_JOIN_REQUEST_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST,
"모임: 이미 처리된 참여 요청입니다. 모임 ID: %s 회원 ID: %s 현재 상태: %s"
),
GROUP_CANNOT_LEAVE_IN_STATUS(HttpStatus.BAD_REQUEST,
"모임: 현재 모임 상태에서는 나가기/신청취소가 불가능합니다. 모임 ID: %s 모임 상태: %s"
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package team.wego.wegobackend.group.v2.application.dto.common;

import java.time.LocalDateTime;
import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2;
import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2Status;

public record TargetMembership(
Long userId,
Long groupUserId,
GroupUserV2Status status,
LocalDateTime joinedAt,
LocalDateTime leftAt
) {
public static TargetMembership of(Long userId, GroupUserV2 groupUserV2) {
return new TargetMembership(
userId,
groupUserV2.getId(),
groupUserV2.getStatus(),
groupUserV2.getJoinedAt(),
groupUserV2.getLeftAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package team.wego.wegobackend.group.v2.application.dto.response;

import java.time.LocalDateTime;
import team.wego.wegobackend.group.v2.application.dto.common.TargetMembership;
import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2;
import team.wego.wegobackend.group.v2.domain.entity.GroupV2;
import team.wego.wegobackend.group.v2.domain.entity.GroupV2JoinPolicy;
import team.wego.wegobackend.group.v2.domain.entity.GroupV2Status;

public record ApproveRejectGroupV2Response(
Long groupId,
GroupV2Status groupStatus,
GroupV2JoinPolicy joinPolicy,
long participantCount,
int maxParticipants,

TargetMembership targetMembership,

LocalDateTime serverTime
) {

public static ApproveRejectGroupV2Response of(
GroupV2 group,
long participantCount,
Long targetUserId,
GroupUserV2 target
) {
return new ApproveRejectGroupV2Response(
group.getId(),
group.getStatus(),
group.getJoinPolicy(),
participantCount,
group.getMaxParticipants(),
TargetMembership.of(targetUserId, target),
LocalDateTime.now()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import team.wego.wegobackend.group.domain.exception.GroupErrorCode;
import team.wego.wegobackend.group.domain.exception.GroupException;
import team.wego.wegobackend.group.v2.application.dto.common.MyMembership;
import team.wego.wegobackend.group.v2.application.dto.response.ApproveRejectGroupV2Response;
import team.wego.wegobackend.group.v2.application.dto.response.AttendanceGroupV2Response;
import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2;
import team.wego.wegobackend.group.v2.domain.entity.GroupUserV2Role;
Expand Down Expand Up @@ -82,7 +83,7 @@ public AttendanceGroupV2Response attend(Long userId, Long groupId) {
}

// 즉시 참여인 경우
if (group.getJoinPolicy() == GroupV2JoinPolicy.INSTANT) {
if (group.getJoinPolicy() == GroupV2JoinPolicy.FREE) {
if (groupUserV2 != null) {
// LEFT, KICKED, REJECTED, CANCELLED -> 재참여
groupUserV2.reAttend(); // 내부에서 BANNED만 막고, ATTEND로 변경
Expand Down Expand Up @@ -192,4 +193,138 @@ public AttendanceGroupV2Response left(Long userId, Long groupId) {
return AttendanceGroupV2Response.of(group, attendCount, membership);
}

@Transactional
public ApproveRejectGroupV2Response approve(Long approverUserId, Long groupId,
Long targetUserId) {
if (approverUserId == null || targetUserId == null) {
throw new GroupException(GroupErrorCode.USER_ID_NULL);
}

if (approverUserId.equals(targetUserId)) {
throw new GroupException(GroupErrorCode.CANNOT_APPROVE_SELF, groupId, approverUserId);
}

GroupV2 group = groupV2Repository.findById(groupId)
.orElseThrow(
() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId));

// 승인제 모임만 가능
if (group.getJoinPolicy() != GroupV2JoinPolicy.APPROVAL_REQUIRED) {
throw new GroupException(GroupErrorCode.GROUP_JOIN_POLICY_NOT_APPROVAL_REQUIRED,
groupId);
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

The error code GROUP_JOIN_POLICY_NOT_APPROVAL_REQUIRED expects two parameters in its message format ("모임 ID: %s 참여 방식: %s"), but it is being called with only one parameter (groupId). This will cause a MissingFormatArgumentException at runtime. You need to either add the join policy as a second parameter or update the error message format to only include the groupId.

Suggested change
groupId);
groupId,
group.getJoinPolicy().name());

Copilot uses AI. Check for mistakes.
}

// 모임 상태 정책
if (group.getStatus() != GroupV2Status.RECRUITING
&& group.getStatus() != GroupV2Status.FULL) {
throw new GroupException(
GroupErrorCode.GROUP_CANNOT_APPROVE_IN_STATUS,
groupId,
group.getStatus().name()
);
}

// 권한 체크: HOST 또는 (그룹 내 MANAGER/… 정책)만 승인 가능
// host는 group.getHost()로 체크
boolean isHost = group.getHost().getId().equals(approverUserId);
boolean canApprove = isHost;

if (!isHost) {
GroupUserV2 approverMembership = groupUserV2Repository.findByGroupIdAndUserId(groupId,
approverUserId)
.orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_USER_NOT_FOUND,
approverUserId));

canApprove = approverMembership.getGroupRole() != null
&& approverMembership.getGroupRole() != GroupUserV2Role.MEMBER;
}

if (!canApprove) {
throw new GroupException(GroupErrorCode.NO_PERMISSION_TO_APPROVE_JOIN, groupId,
approverUserId);
}
Comment on lines +227 to +245
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

권한 검증 로직 중복

approve와 reject 메서드에서 동일한 권한 검증 로직이 반복됩니다(lines 223-241, 291-307). 이를 별도 메서드로 추출하여 중복을 제거하고 유지보수성을 향상시키세요.

🔎 리팩터링 제안
private void validateApprovalPermission(Long userId, Long groupId, GroupV2 group) {
    boolean isHost = group.getHost().getId().equals(userId);
    if (isHost) {
        return;
    }
    
    GroupUserV2 membership = groupUserV2Repository.findByGroupIdAndUserId(groupId, userId)
            .orElseThrow(() -> new GroupException(
                GroupErrorCode.GROUP_USER_NOT_FOUND, userId));
    
    boolean hasPermission = membership.getGroupRole() != null
            && membership.getGroupRole() != GroupUserV2Role.MEMBER;
    
    if (!hasPermission) {
        throw new GroupException(
            GroupErrorCode.NO_PERMISSION_TO_APPROVE_JOIN, groupId, userId);
    }
}

그런 다음 approve/reject 메서드에서:

validateApprovalPermission(approverUserId, groupId, group);

Also applies to: 291-307

🤖 Prompt for AI Agents
src/main/java/team/wego/wegobackend/group/v2/application/service/GroupV2AttendanceService.java
lines 223-241 and 291-307: duplicate permission-check logic should be extracted
into a private helper method; implement a method (e.g.,
validateApprovalPermission(Long userId, Long groupId, GroupV2 group)) that
returns early if user is host, otherwise loads membership via
groupUserV2Repository.findByGroupIdAndUserId(...).orElseThrow(GROUP_USER_NOT_FOUND
with userId), then verifies membership.getGroupRole() is not null and not
GroupUserV2Role.MEMBER and throws NO_PERMISSION_TO_APPROVE_JOIN with groupId and
userId if not; replace the duplicated blocks in both approve and reject with a
single call to this new helper.


GroupUserV2 target = groupUserV2Repository.findByGroupIdAndUserId(groupId, targetUserId)
.orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_USER_NOT_FOUND,
targetUserId));

// PENDING만 승인 가능 (도메인에서 검증)
target.approveJoin();

long attendCount = groupUserV2Repository.countByGroupIdAndStatus(groupId,
GroupUserV2Status.ATTEND);

if (attendCount > group.getMaxParticipants()) {
throw new GroupException(GroupErrorCode.GROUP_IS_FULL, groupId);
}

// FULL 자동 전환
if (attendCount == group.getMaxParticipants()
&& group.getStatus() == GroupV2Status.RECRUITING) {
group.changeStatus(GroupV2Status.FULL);
}

return ApproveRejectGroupV2Response.of(group, attendCount, targetUserId, target);
}

@Transactional
public ApproveRejectGroupV2Response reject(Long approverUserId, Long groupId,
Long targetUserId) {
if (approverUserId == null || targetUserId == null) {
throw new GroupException(GroupErrorCode.USER_ID_NULL);
}

if (approverUserId.equals(targetUserId)) {
throw new GroupException(GroupErrorCode.CANNOT_REJECT_SELF, groupId, approverUserId);
}

GroupV2 group = groupV2Repository.findById(groupId)
.orElseThrow(
() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND_BY_ID, groupId));

if (group.getJoinPolicy() != GroupV2JoinPolicy.APPROVAL_REQUIRED) {
throw new GroupException(GroupErrorCode.GROUP_JOIN_POLICY_NOT_APPROVAL_REQUIRED,
groupId);
Comment on lines +286 to +287
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

The error code GROUP_JOIN_POLICY_NOT_APPROVAL_REQUIRED expects two parameters in its message format ("모임 ID: %s 참여 방식: %s"), but it is being called with only one parameter (groupId). This will cause a MissingFormatArgumentException at runtime. You need to either add the join policy as a second parameter or update the error message format to only include the groupId.

Suggested change
throw new GroupException(GroupErrorCode.GROUP_JOIN_POLICY_NOT_APPROVAL_REQUIRED,
groupId);
throw new GroupException(
GroupErrorCode.GROUP_JOIN_POLICY_NOT_APPROVAL_REQUIRED,
groupId,
group.getJoinPolicy().name()
);

Copilot uses AI. Check for mistakes.
}

if (group.getStatus() != GroupV2Status.RECRUITING
&& group.getStatus() != GroupV2Status.FULL) {
throw new GroupException(
GroupErrorCode.GROUP_CANNOT_REJECT_IN_STATUS,
groupId,
group.getStatus().name()
);
}

boolean isHost = group.getHost().getId().equals(approverUserId);
boolean canReject = isHost;

if (!isHost) {
GroupUserV2 approverMembership = groupUserV2Repository.findByGroupIdAndUserId(groupId,
approverUserId)
.orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_USER_NOT_FOUND,
approverUserId));

canReject = approverMembership.getGroupRole() != null
&& approverMembership.getGroupRole() != GroupUserV2Role.MEMBER; // 예시
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

The inline comment "예시" (example) on line 301 suggests that this is example code rather than production-ready logic. This indicates that the role-based permission logic may not be finalized. Consider removing this comment if the logic is correct, or if it's still under consideration, add a TODO comment to clarify what needs to be decided or implemented.

Suggested change
&& approverMembership.getGroupRole() != GroupUserV2Role.MEMBER; // 예시
&& approverMembership.getGroupRole() != GroupUserV2Role.MEMBER; // TODO: 권한 정책 확정 후, REJECT 가능 역할 조건(현재: MEMBER 외 모든 역할)을 재검토할 것

Copilot uses AI. Check for mistakes.
}

if (!canReject) {
throw new GroupException(GroupErrorCode.NO_PERMISSION_TO_REJECT_JOIN, groupId,
approverUserId);
}

GroupUserV2 target = groupUserV2Repository.findByGroupIdAndUserId(groupId, targetUserId)
.orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_USER_NOT_FOUND,
targetUserId));

target.rejectJoin();

long attendCount = groupUserV2Repository.countByGroupIdAndStatus(groupId,
GroupUserV2Status.ATTEND);

// reject는 ATTEND 수가 바뀌지 않는 게 일반적이지만(대상은 PENDING),
// 혹시라도 상태 정책이 바뀌더라도 count는 최신으로 내려가도록 유지
return ApproveRejectGroupV2Response.of(group, attendCount, targetUserId, target);
}
Comment on lines 196 to 329
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

There is significant code duplication between the approve and reject methods. Both methods share identical validation logic for checking join policy, group status, and user permissions. Consider extracting the common validation logic into a private helper method to improve maintainability and reduce duplication. This would make the code easier to maintain and reduce the risk of inconsistencies if validation logic needs to change in the future.

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,33 @@ public void leaveOrCancel() {
);
}
}

public void approveJoin() {
if (this.status != GroupUserV2Status.PENDING) {
throw new GroupException(
GroupErrorCode.GROUP_USER_STATUS_NOT_ALLOWED_TO_APPROVE,
this.group.getId(),
this.user.getId(),
this.status.name()
);
}
this.status = GroupUserV2Status.ATTEND;
this.joinedAt = LocalDateTime.now();
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

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

The joinedAt timestamp is being set when approving a join request, but this is semantically incorrect. The joinedAt timestamp was already set when the user initially made the PENDING request. Approving should only change the status to ATTEND and should not modify joinedAt. Only leftAt should be cleared. This could cause confusion when tracking when a user actually requested to join versus when they were approved.

Suggested change
this.joinedAt = LocalDateTime.now();

Copilot uses AI. Check for mistakes.
this.leftAt = null;
}

public void rejectJoin() {
if (this.status != GroupUserV2Status.PENDING) {
throw new GroupException(
GroupErrorCode.GROUP_USER_STATUS_NOT_ALLOWED_TO_REJECT,
this.group.getId(),
this.user.getId(),
this.status.name()
);
}
this.status = GroupUserV2Status.REJECTED;
this.leftAt = LocalDateTime.now();
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public static GroupV2 create(
group.description = description;
group.maxParticipants = maxParticipants;
group.host = host;
group.joinPolicy = (joinPolicy == null) ? GroupV2JoinPolicy.INSTANT : joinPolicy;
group.joinPolicy = (joinPolicy == null) ? GroupV2JoinPolicy.FREE : joinPolicy;
group.status = GroupV2Status.RECRUITING;
return group;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package team.wego.wegobackend.group.v2.domain.entity;

public enum GroupV2JoinPolicy {
INSTANT, // 참여 버튼 누르면 즉시 ATTEND
FREE, // 참여 버튼 누르면 즉시 ATTEND
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "GroupV2JoinPolicy.java" 2>/dev/null | head -20

Repository: WeGo-Together/WeGo_BackEnd

Length of output: 153


🏁 Script executed:

# Search for any references to INSTANT in Java files (excluding the main file itself)
rg -n '\bINSTANT\b' --type=java 2>/dev/null | head -50

Repository: WeGo-Together/WeGo_BackEnd

Length of output: 52


🏁 Script executed:

# Search for references to "INSTANT" in test HTTP files
rg -n '"INSTANT"' src/test 2>/dev/null | head -50

Repository: WeGo-Together/WeGo_BackEnd

Length of output: 52


🏁 Script executed:

cat -n ./src/main/java/team/wego/wegobackend/group/v2/domain/entity/GroupV2JoinPolicy.java

Repository: WeGo-Together/WeGo_BackEnd

Length of output: 367


🏁 Script executed:

# Check for INSTANT in configuration files, JSON, YAML, properties
rg -n 'INSTANT' --type-add 'config:*.{properties,yml,yaml,json,xml}' -tcfg 2>/dev/null || echo "No config files with INSTANT found"

Repository: WeGo-Together/WeGo_BackEnd

Length of output: 104


🏁 Script executed:

# Search for INSTANT in all non-Java files (broader search)
rg -n '\bINSTANT\b' --type-list | grep -v java | head -20
# Then search those types
rg -n '\bINSTANT\b' -g '!*.java' 2>/dev/null | head -50

Repository: WeGo-Together/WeGo_BackEnd

Length of output: 798


🏁 Script executed:

# Verify the enum is correctly used in other classes
rg -n 'GroupV2JoinPolicy\.' --type=java -A 2 2>/dev/null | head -100

Repository: WeGo-Together/WeGo_BackEnd

Length of output: 2389


enum 상수 이름 변경 확인

INSTANT에서 FREE로 enum 상수명이 변경되었습니다. Java 코드베이스의 모든 참조가 올바르게 업데이트되었으나, 테스트 파일 src/test/http/group/v2/v2-group-attend-free.http (라인 52, 58, 65)의 주석에서 "INSTANT"에 대한 설명이 여전히 남아있습니다. 이 문서 주석도 "FREE"로 업데이트하세요.

🤖 Prompt for AI Agents
In
src/main/java/team/wego/wegobackend/group/v2/domain/entity/GroupV2JoinPolicy.java
line 4 the enum constant was renamed from INSTANT to FREE; update the related
test documentation comments in src/test/http/group/v2/v2-group-attend-free.http
(around lines 52, 58, 65) to replace any remaining "INSTANT" mentions with
"FREE" so the comments match the current enum name.

APPROVAL_REQUIRED, // 참여 버튼 누르면 신청 상태로 들어가고 HOST 승인 후 ATTEND
INVITE_ONLY // (미정)초대 받은 사람만 참여
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import team.wego.wegobackend.group.v2.application.dto.request.GroupListFilter;
import team.wego.wegobackend.group.v2.application.dto.request.MyGroupTypeV2;
import team.wego.wegobackend.group.v2.application.dto.request.UpdateGroupV2Request;
import team.wego.wegobackend.group.v2.application.dto.response.ApproveRejectGroupV2Response;
import team.wego.wegobackend.group.v2.application.dto.response.AttendanceGroupV2Response;
import team.wego.wegobackend.group.v2.application.dto.response.CreateGroupV2Response;
import team.wego.wegobackend.group.v2.application.dto.response.GetGroupListV2Response;
Expand Down Expand Up @@ -166,4 +167,29 @@ public ResponseEntity<Void> delete(

return ResponseEntity.noContent().build();
}


@PostMapping("/{groupId}/attendance/{targetUserId}/approve")
public ResponseEntity<ApiResponse<ApproveRejectGroupV2Response>> approve(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long groupId,
@PathVariable Long targetUserId
) {
ApproveRejectGroupV2Response response =
groupV2AttendanceService.approve(userDetails.getId(), groupId, targetUserId);

return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK.value(), response));
}

@PostMapping("/{groupId}/attendance/{targetUserId}/reject")
public ResponseEntity<ApiResponse<ApproveRejectGroupV2Response>> reject(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long groupId,
@PathVariable Long targetUserId
) {
ApproveRejectGroupV2Response response =
groupV2AttendanceService.reject(userDetails.getId(), groupId, targetUserId);

return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK.value(), response));
}
}
2 changes: 1 addition & 1 deletion src/test/http/group/v2/V2-group-left.http
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Authorization: Bearer {{host2AccessToken}}
{
"title": "V2 left() 테스트용 모임",
"location": "서울 서초구",
"joinPolicy": "FREE",
"locationDetail": "교대역 1번 출구 근처 카페",
"startTime": "2026-12-20T19:00:00",
"endTime": "2026-12-20T21:00:00",
Expand Down Expand Up @@ -162,7 +163,6 @@ Authorization: Bearer {{left_member1AccessToken}}
{}

### 5-2. 예외: MEMBER 1 다시 left() 시도
# ✅ 현재 도메인 로직 기준: LEFT 상태면 ALREADY_LEFT_GROUP (또는 유사 에러코드)로 떨어지는 게 자연스러움
POST http://localhost:8080/api/v2/groups/{{groupId_leftTest}}/left
Content-Type: application/json
Authorization: Bearer {{left_member1AccessToken}}
Expand Down
Loading