Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
27fb73c
Feat: 스웨거 server url 환경 변수로 설정할 수 있도록 수정
dungbik Aug 9, 2025
a1f8858
Chore: 오타 수정
dungbik Aug 9, 2025
5734aff
Feat: 그룹 초대 기능
dungbik Aug 9, 2025
389912f
Docs: 그룹 초대 기능 서비스 코드에 JavaDoc 작성
dungbik Aug 10, 2025
4f562fb
Refactor: 그룹 회원/비회원 초대 테이블 통합
dungbik Aug 10, 2025
1c8cf19
Feat: 그룹 초대 취소 기능
dungbik Aug 10, 2025
a83d967
Docs: 그룹 초대 취소 스웨거 정보 추가
dungbik Aug 10, 2025
0de90c6
Feat: 그룹 초대 응답 기능
dungbik Aug 11, 2025
e26fe13
Feat: 그룹 초대시 그룹 초대 id를 응답하도록 수정
dungbik Aug 11, 2025
5d3a0a7
Feat: 그룹 초대 목록 조회 기능
dungbik Aug 11, 2025
e85cb58
Feat: 회원가입시 비회원 그룹 초대 수락되는 기능
dungbik Aug 11, 2025
933340d
Docs: GroupInvitationService JavaDoc 빠진 부분 추가
dungbik Aug 11, 2025
c725313
Feat: 회원가입 후속 처리가 트랜잭션이 성공했을 때만 동작하도록 수정
dungbik Aug 12, 2025
442f49a
Feat: 그룹 초대 받은 목록 조회 기능
dungbik Aug 12, 2025
4c632a5
Docs: 그룹 초대 API Swagger Docs에 보안 스키마 명시
dungbik Aug 12, 2025
418215f
Feat: 그룹 초대 목록 조회 API 페이지 파라미터 유효성 검증 추가
dungbik Aug 12, 2025
0eb2af2
Refactor: UserIdNickname 프로젝션 위치 이동
dungbik Aug 12, 2025
62214bf
Refactor: 그룹 초대 엔티티 인덱스 및 유니크 제약 추가
dungbik Aug 12, 2025
f3ae3f3
Feat: 그룹에 회원 추가시 존재 여부 검증 후 추가하도록 수정
dungbik Aug 12, 2025
e50dbe4
Feat: 그룹 초대 수락시 그룹 최대 가입수를 검증 후 가입되도록 수정
dungbik Aug 12, 2025
48658f3
Feat: 그룹 초대시 검증 조건 수정 및 강화
dungbik Aug 12, 2025
c1e0018
Refactor: 회원 닉네임 목록 조회 메서드 빈 입력/중복 처리 및 toMap 충돌 방지
dungbik Aug 12, 2025
d67c104
Feat: 그룹 초대 받은 목록 조회 API 엔드포인트 수정
dungbik Aug 12, 2025
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
3 changes: 3 additions & 0 deletions src/main/java/project/flipnote/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import project.flipnote.auth.util.VerificationCodeGenerator;
import project.flipnote.common.config.ClientProperties;
import project.flipnote.common.dto.UserCreateCommand;
import project.flipnote.common.event.UserRegisteredEvent;
import project.flipnote.common.exception.BizException;
import project.flipnote.common.security.dto.AuthPrinciple;
import project.flipnote.common.security.jwt.JwtComponent;
Expand Down Expand Up @@ -77,6 +78,8 @@ public UserRegisterResponse register(UserRegisterRequest req) {
.build();
userAuthRepository.save(userAuth);

eventPublisher.publishEvent(new UserRegisteredEvent(userId, email));

return UserRegisterResponse.from(userId);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package project.flipnote.common.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

Expand All @@ -8,14 +9,18 @@
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import project.flipnote.common.security.jwt.JwtConstants;
import io.swagger.v3.oas.models.servers.Server;

@Configuration
public class SwaggerConfig {

@Value("${springdoc.server.url}")
private String serverUrl;

@Bean
public OpenAPI openApi() {
return new OpenAPI()
.addServersItem(new Server().url(serverUrl))
.addSecurityItem(
new SecurityRequirement()
.addList("access-token")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package project.flipnote.common.event;

public record UserRegisteredEvent(
Long userId,
String email
) {
}
32 changes: 32 additions & 0 deletions src/main/java/project/flipnote/common/response/PageResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package project.flipnote.common.response;

import java.util.List;

import org.springframework.data.domain.Page;

public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages,
boolean first,
boolean last,
boolean hasNext,
boolean hasPrevious
) {

public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isFirst(),
page.isLast(),
page.hasNext(),
page.hasPrevious()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
@Getter
@RequiredArgsConstructor
public enum SecurityErrorCode implements ErrorCode {
TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "SECURITY_0021", "토큰이 만료되었습니다."),
TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "SECURITY_001", "토큰이 만료되었습니다."),
NOT_VALID_JWT_TOKEN(HttpStatus.UNAUTHORIZED.value(), "SECURITY_002", "올바르지 않은 토큰입니다."),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED.value(), "SECURITY_003", "인증이 필요합니다."),
FORBIDDEN(HttpStatus.FORBIDDEN.value(), "SECURITY_004", "권한이 없습니다.");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package project.flipnote.group.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import project.flipnote.common.security.dto.AuthPrinciple;
import project.flipnote.group.controller.docs.GroupInvitationControllerDocs;
import project.flipnote.group.model.GroupInvitationCreateRequest;
import project.flipnote.group.model.GroupInvitationCreateResponse;
import project.flipnote.group.model.GroupInvitationRespondRequest;
import project.flipnote.group.service.GroupInvitationService;

@RequiredArgsConstructor
@RestController
@RequestMapping("/v1/groups/{groupId}/invitations")
public class GroupInvitationController implements GroupInvitationControllerDocs {

private final GroupInvitationService groupInvitationService;

@PostMapping
public ResponseEntity<GroupInvitationCreateResponse> createGroupInvitation(
@PathVariable("groupId") Long groupId,
@Valid @RequestBody GroupInvitationCreateRequest req,
@AuthenticationPrincipal AuthPrinciple authPrinciple
) {
GroupInvitationCreateResponse res = groupInvitationService.createGroupInvitation(authPrinciple, groupId, req);

return ResponseEntity.status(HttpStatus.CREATED).body(res);
}

@DeleteMapping("/{invitationId}")
public ResponseEntity<Void> deleteGroupInvitation(
@PathVariable("groupId") Long groupId,
@PathVariable("invitationId") Long invitationId,
@AuthenticationPrincipal AuthPrinciple authPrinciple
) {
groupInvitationService.deleteGroupInvitation(authPrinciple.userId(), groupId, invitationId);

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

@PatchMapping("/{invitationId}")
public ResponseEntity<Void> respondToGroupInvitation(
@PathVariable("groupId") Long groupId,
@PathVariable("invitationId") Long invitationId,
@Valid @RequestBody GroupInvitationRespondRequest req,
@AuthenticationPrincipal AuthPrinciple authPrinciple
) {
groupInvitationService.respondToGroupInvitation(authPrinciple.userId(), groupId, invitationId, req);

return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package project.flipnote.group.controller;

import org.springframework.http.ResponseEntity;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import project.flipnote.common.response.PageResponse;
import project.flipnote.common.security.dto.AuthPrinciple;
import project.flipnote.group.controller.docs.GroupInvitationQueryControllerDocs;
import project.flipnote.group.model.IncomingGroupInvitationResponse;
import project.flipnote.group.model.OutgoingGroupInvitationResponse;
import project.flipnote.group.service.GroupInvitationService;

@RequiredArgsConstructor
@RestController
@RequestMapping("/v1")
public class GroupInvitationQueryController implements GroupInvitationQueryControllerDocs {

private final GroupInvitationService groupInvitationService;

@GetMapping("/groups/{groupId}/invitations")
public ResponseEntity<PageResponse<OutgoingGroupInvitationResponse>> getOutgoingInvitations(
@PathVariable("groupId") Long groupId,
@Min(0) @RequestParam(defaultValue = "0") int page,
@Min(1) @Min(30) @RequestParam(defaultValue = "20") int size,
@AuthenticationPrincipal AuthPrinciple authPrinciple
) {
PageResponse<OutgoingGroupInvitationResponse> res
= groupInvitationService.getOutgoingInvitations(authPrinciple.userId(), groupId, page, size);

return ResponseEntity.ok(res);
}

@GetMapping("/group-invitations")
public ResponseEntity<PageResponse<IncomingGroupInvitationResponse>> getIncomingInvitations(
@Min(0) @RequestParam(defaultValue = "0") int page,
@Min(1) @Min(30) @RequestParam(defaultValue = "20") int size,
@AuthenticationPrincipal AuthPrinciple authPrinciple
) {
PageResponse<IncomingGroupInvitationResponse> res
= groupInvitationService.getIncomingInvitations(authPrinciple.userId(), page, size);

return ResponseEntity.ok(res);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package project.flipnote.group.controller.docs;

import org.springframework.http.ResponseEntity;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import project.flipnote.common.security.dto.AuthPrinciple;
import project.flipnote.group.model.GroupInvitationCreateRequest;
import project.flipnote.group.model.GroupInvitationCreateResponse;
import project.flipnote.group.model.GroupInvitationRespondRequest;

@Tag(name = "Group Invitation", description = "Group Invitation API")
public interface GroupInvitationControllerDocs {

@Operation(summary = "그룹 초대", security = {@SecurityRequirement(name = "access-token")})
ResponseEntity<GroupInvitationCreateResponse> createGroupInvitation(
Long groupId, GroupInvitationCreateRequest req, AuthPrinciple authPrinciple
);

@Operation(summary = "그룹 초대 취소", security = {@SecurityRequirement(name = "access-token")})
ResponseEntity<Void> deleteGroupInvitation(Long groupId, Long invitationId, AuthPrinciple authPrinciple);

@Operation(summary = "그룹 초대 응답", security = {@SecurityRequirement(name = "access-token")})
ResponseEntity<Void> respondToGroupInvitation(
Long groupId, Long invitationId, GroupInvitationRespondRequest req, AuthPrinciple authPrinciple
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package project.flipnote.group.controller.docs;

import org.springframework.http.ResponseEntity;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import project.flipnote.common.response.PageResponse;
import project.flipnote.common.security.dto.AuthPrinciple;
import project.flipnote.group.model.IncomingGroupInvitationResponse;
import project.flipnote.group.model.OutgoingGroupInvitationResponse;

@Tag(name = "Group Invitation Query", description = "Group Invitation Query API")
public interface GroupInvitationQueryControllerDocs {

@Operation(summary = "그룹 초대 보낸 목록 조회", security = {@SecurityRequirement(name = "access-token")})
ResponseEntity<PageResponse<OutgoingGroupInvitationResponse>> getOutgoingInvitations(
Long groupId,
int page,
int size,
AuthPrinciple authPrinciple
);

@Operation(summary = "그룹 초대 받은 목록 조회", security = {@SecurityRequirement(name = "access-token")})
ResponseEntity<PageResponse<IncomingGroupInvitationResponse>> getIncomingInvitations(
int page,
int size,
AuthPrinciple authPrinciple
);
}
8 changes: 8 additions & 0 deletions src/main/java/project/flipnote/group/entity/Group.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import lombok.Getter;
import lombok.NoArgsConstructor;
import project.flipnote.common.entity.BaseEntity;
import project.flipnote.common.exception.BizException;
import project.flipnote.group.exception.GroupErrorCode;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
Expand Down Expand Up @@ -67,4 +69,10 @@ private Group(
this.maxMember = maxMember;
this.imageUrl = imageUrl;
}

public void validateJoinable() {
if (maxMember < 1 || maxMember >= 100) {
throw new BizException(GroupErrorCode.INVALID_MAX_MEMBER);
}
}
Comment on lines +73 to +77
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

maxMember 유효성 검증 범위 불일치 문제

Line 74에서 maxMember >= 100으로 상한을 99로 제한하는데, 이는 기존 JPA 어노테이션(@Max(100))과 GroupService.validateMaxMember()의 1-100 범위와 일치하지 않습니다.

관련 코드:

  • Line 49: @Max(100) 어노테이션은 100까지 허용
  • GroupService.validateMaxMember(): 1 <= maxMember <= 100 범위 검증

다음 중 하나로 통일해주세요:

public void validateJoinable() {
-    if (maxMember < 1 || maxMember >= 100) {
+    if (maxMember < 1 || maxMember > 100) {
        throw new BizException(GroupErrorCode.INVALID_MAX_MEMBER);
    }
}

또는 JPA 어노테이션과 GroupService 로직을 99로 통일하세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void validateJoinable() {
if (maxMember < 1 || maxMember >= 100) {
throw new BizException(GroupErrorCode.INVALID_MAX_MEMBER);
}
}
public void validateJoinable() {
if (maxMember < 1 || maxMember > 100) {
throw new BizException(GroupErrorCode.INVALID_MAX_MEMBER);
}
}
🤖 Prompt for AI Agents
In src/main/java/project/flipnote/group/entity/Group.java around lines 73 to 77,
the upper bound check currently disallows 100 (uses maxMember >= 100) which
contradicts the @Max(100) JPA annotation and GroupService.validateMaxMember
(1–100). Change the validation to allow 100 by using a strict greater-than check
(maxMember > 100) so the method enforces 1 <= maxMember <= 100 and stays
consistent with the annotation and service validation.

}
70 changes: 70 additions & 0 deletions src/main/java/project/flipnote/group/entity/GroupInvitation.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package project.flipnote.group.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import project.flipnote.common.entity.BaseEntity;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(
name = "group_invitation",
indexes = {
@Index(name = "idx_group_invitee_user", columnList = "group_id, invitee_user_id, status"),
@Index(name = "idx_group_invitee_email", columnList = "group_id, invitee_email, status"),
@Index(name = "idx_invitee_user_status", columnList = "invitee_user_id, status"),
@Index(name = "idx_invitee_email_status", columnList = "invitee_email, status")
},
uniqueConstraints = {
@UniqueConstraint(name = "uq_group_invitee_user", columnNames = {"group_id", "invitee_user_id"}),
@UniqueConstraint(name = "uq_group_invitee_email", columnNames = {"group_id", "invitee_email"})
}
)
public class GroupInvitation extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne
@JoinColumn(name = "group_id", nullable = false)
private Group group;

@Column(nullable = false)
private Long inviterUserId;

private Long inviteeUserId;

private String inviteeEmail;

Comment on lines +50 to +53
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

엔터티 불변식 강화: 회원/비회원 초대 상호 배타성 보장

현재 inviteeUserId, inviteeEmail 둘 다 또는 둘 다 null인 잘못된 상태의 엔터티가 생성될 수 있습니다. 생성 경로를 두 개로 분리(회원/비회원)하거나 팩토리 메서드로 불변식을 강제하세요. Builder 접근을 제한하는 것도 방법입니다.

예시(접근 제한 + 팩토리):

-@Builder
-public GroupInvitation(Long groupId, Long inviterUserId, Long inviteeUserId, String inviteeEmail) {
+@Builder(access = AccessLevel.PRIVATE)
+public GroupInvitation(Long groupId, Long inviterUserId, Long inviteeUserId, String inviteeEmail) {
   ...
 }
+
+public static GroupInvitation forUser(Long groupId, Long inviterUserId, Long inviteeUserId, String inviteeEmail) {
+  if (inviteeUserId == null || inviteeEmail == null) throw new IllegalArgumentException("회원 초대는 userId와 email이 모두 필요합니다.");
+  return GroupInvitation.builder()
+      .groupId(groupId).inviterUserId(inviterUserId)
+      .inviteeUserId(inviteeUserId).inviteeEmail(inviteeEmail)
+      .build();
+}
+
+public static GroupInvitation forGuest(Long groupId, Long inviterUserId, String inviteeEmail) {
+  if (inviteeEmail == null || inviteeEmail.isBlank()) throw new IllegalArgumentException("비회원 초대는 email이 필요합니다.");
+  return GroupInvitation.builder()
+      .groupId(groupId).inviterUserId(inviterUserId)
+      .inviteeUserId(null).inviteeEmail(inviteeEmail.toLowerCase())
+      .build();
+}

추가로, 이메일은 저장 전 소문자 정규화(toLowerCase) 일관 적용을 추천합니다.

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

🤖 Prompt for AI Agents
In src/main/java/project/flipnote/group/entity/GroupInvitation.java around lines
33 to 36, the fields inviteeUserId and inviteeEmail can both be set or both
null, violating the invariant that an invitation must be either for a member
(userId) or a non-member (email); change the class to enforce exclusivity by
making constructors private (or package-private), add two explicit factory
methods like createForUser(Long userId) and createForEmail(String email) that
validate the inputs and throw IllegalArgumentException on invalid combinations,
restrict or remove public builder access so callers must use the factories, and
ensure inviteeEmail is normalized to lowercase before assignment/storage.

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private GroupInvitationStatus status;

@Builder
public GroupInvitation(Group group, Long inviterUserId, Long inviteeUserId, String inviteeEmail) {
this.group = group;
this.inviterUserId = inviterUserId;
this.inviteeUserId = inviteeUserId;
this.inviteeEmail = inviteeEmail;
this.status = GroupInvitationStatus.PENDING;
}

public void respond(GroupInvitationStatus status) {
this.status = status;
}
Comment on lines +67 to +69
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

상태 전이 제약 추가 (PENDING -> ACCEPTED/REJECTED만 허용)

엔티티 차원에서 불변식(이미 처리된 초대는 재처리 불가, PENDING으로 회귀 불가)을 보장하세요.

 public void respond(GroupInvitationStatus status) {
-    this.status = status;
+    if (this.status != GroupInvitationStatus.PENDING) {
+        throw new IllegalStateException("이미 처리된 초대입니다.");
+    }
+    if (status == GroupInvitationStatus.PENDING) {
+        throw new IllegalArgumentException("PENDING 상태로의 변경은 허용되지 않습니다.");
+    }
+    this.status = status;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void respond(GroupInvitationStatus status) {
this.status = status;
}
public void respond(GroupInvitationStatus status) {
if (this.status != GroupInvitationStatus.PENDING) {
throw new IllegalStateException("이미 처리된 초대입니다.");
}
if (status == GroupInvitationStatus.PENDING) {
throw new IllegalArgumentException("PENDING 상태로의 변경은 허용되지 않습니다.");
}
this.status = status;
}
🤖 Prompt for AI Agents
In src/main/java/project/flipnote/group/entity/GroupInvitation.java around lines
50 to 52, the respond method currently allows any status assignment; enforce the
invariant that only transitions from PENDING to ACCEPTED or REJECTED are allowed
and prevent re-processing or reverting to PENDING: check that this.status ==
GroupInvitationStatus.PENDING and the incoming status is either ACCEPTED or
REJECTED, otherwise throw an IllegalStateException (or a domain-specific
exception) with a clear message; also validate the incoming status is non-null
before assignment.

Comment on lines +67 to +69
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

상태 전이 검증 부족: 이미 처리된 초대 재변경 가능

respond는 모든 상태에서 임의 상태로 변경 가능합니다. PENDING → {ACCEPTED, REJECTED}로만 허용하고, 그 외 전이는 막아야 데이터 무결성이 보장됩니다.

- public void respond(GroupInvitationStatus status) {
-   this.status = status;
- }
+ public void respond(GroupInvitationStatus status) {
+   if (this.status != GroupInvitationStatus.PENDING) {
+     throw new IllegalStateException("이미 처리된 초대입니다. 현재 상태=" + this.status);
+   }
+   if (status == null || status == GroupInvitationStatus.PENDING) {
+     throw new IllegalArgumentException("유효하지 않은 상태 전이입니다: " + status);
+   }
+   this.status = status;
+ }

상태 전이를 엔티티에서 강제하면 서비스 계층의 실수도 방지할 수 있습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void respond(GroupInvitationStatus status) {
this.status = status;
}
public void respond(GroupInvitationStatus status) {
if (this.status != GroupInvitationStatus.PENDING) {
throw new IllegalStateException("이미 처리된 초대입니다. 현재 상태=" + this.status);
}
if (status == null || status == GroupInvitationStatus.PENDING) {
throw new IllegalArgumentException("유효하지 않은 상태 전이입니다: " + status);
}
this.status = status;
}
🤖 Prompt for AI Agents
In src/main/java/project/flipnote/group/entity/GroupInvitation.java around lines
50 to 52, the respond method currently allows arbitrary status changes; restrict
transitions so only PENDING may move to ACCEPTED or REJECTED. Update respond to
check this.status == PENDING before assigning and throw an IllegalStateException
(or custom exception) for invalid transitions; also validate the incoming status
is either ACCEPTED or REJECTED and add unit tests to cover accepted, rejected,
and invalid transition cases.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package project.flipnote.group.entity;

public enum GroupInvitationStatus {
PENDING, ACCEPTED, REJECTED
}
Comment on lines +3 to +5
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

초대 상태 확장 및 매핑 방식 확인 제안

  • 취소/만료 시나리오(CANCELED/EXPIRED 등)를 상태값으로 미리 고려하면 워크플로우가 단순해집니다. 이후 리스폰스/쿼리 필터링에도 유용합니다.
  • JPA 매핑은 반드시 EnumType.STRING 사용을 권장합니다(ordinal 사용 시 상태 추가/순서 변경에 따른 데이터 망가짐 위험).

초대 엔티티에서 문자열 매핑이 적용되어 있는지 확인 스크립트:


🏁 Script executed:

#!/bin/bash
rg -n "class GroupInvitation|@Enumerated|GroupInvitationStatus" -A 10 -B 5

Length of output: 33404


GroupInvitationStatus enum 확장 제안 및 매핑 적용 확인

  • src/main/java/project/flipnote/group/entity/GroupInvitationStatus.java
    • CANCELED, EXPIRED 등 취소·만료 시나리오를 미리 상태값으로 추가하면 워크플로우 단순화 및 응답·쿼리 필터링에 유용합니다.
  • src/main/java/project/flipnote/group/entity/GroupInvitation.java
    @Enumerated(EnumType.STRING)이 이미 적용되어 있어 ordinal 기반 데이터 손상 위험이 없습니다.
🤖 Prompt for AI Agents
In src/main/java/project/flipnote/group/entity/GroupInvitationStatus.java lines
3-5, extend the enum to include additional terminal states such as CANCELED and
EXPIRED (e.g., PENDING, ACCEPTED, REJECTED, CANCELED, EXPIRED) so workflows and
filters can use explicit cancel/expiry statuses; confirm and leave the existing
@Enumerated(EnumType.STRING) mapping on
src/main/java/project/flipnote/group/entity/GroupInvitation.java as-is to avoid
ordinal issues, and then update any switch/case logic, repository query filters,
DTOs and tests that rely on the enum to handle the new values accordingly.

Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
@RequiredArgsConstructor
public enum GroupErrorCode implements ErrorCode {
GROUP_NOT_FOUND(HttpStatus.NOT_FOUND, "GROUP_002", "그룹이 존재하지 않습니다."),
INVALID_MAX_MEMBER(HttpStatus.BAD_REQUEST, "GROUP_001", "최대 인원 수는 1 이상 100 이하여야 합니다.");
INVALID_MAX_MEMBER(HttpStatus.BAD_REQUEST, "GROUP_001", "최대 인원 수는 1 이상 100 이하여야 합니다."),
USER_NOT_IN_GROUP(HttpStatus.NOT_FOUND, "GROUP_003", "그룹에 유저가 존재하지 않습니다.");
Comment on lines +14 to +15
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

권한/존재의 의미적 구분에 따른 HTTP 상태 정합성 재검토(403 vs 404)

  • “그룹에 유저가 존재하지 않습니다”는 대개 “멤버가 아님(권한 없음)” 상황으로, 도메인 일관성상 403(Forbidden)이 더 자연스럽습니다.
  • CardSetErrorCode.GROUP_MEMBER_NOT_FOUND도 403을 사용 중이라(유사 의미), 코드 간 정합성을 맞추는 것을 권장합니다.
  • 메시지도 “해당 그룹의 멤버가 아닙니다.”로 통일하면 혼선을 줄일 수 있습니다.

권장 수정안(diff):

-	USER_NOT_IN_GROUP(HttpStatus.NOT_FOUND, "GROUP_003", "그룹에 유저가 존재하지 않습니다.");
+	USER_NOT_IN_GROUP(HttpStatus.FORBIDDEN, "GROUP_003", "해당 그룹의 멤버가 아닙니다.");

본 에러코드 사용부에서 클라이언트 기대 동작과의 일치 여부를 확인하세요:


🏁 Script executed:

#!/bin/bash
rg -n "GroupErrorCode\.USER_NOT_IN_GROUP|GROUP_003"

Length of output: 326


🏁 Script executed:

#!/bin/bash
rg -n "GROUP_MEMBER_NOT_FOUND" -A2 -B2

Length of output: 1543


GroupErrorCode.USER_NOT_IN_GROUP의 HTTP 상태 및 메시지 일관성 수정 필요

도메인 관점에서 “그룹에 유저가 존재하지 않습니다”는 권한(Forbidden) 문제로, CardSetErrorCode.GROUP_MEMBER_NOT_FOUND에서도 403을 사용하고 있어 두 코드를 맞추는 것이 좋습니다.

수정 대상:

  • src/main/java/project/flipnote/group/exception/GroupErrorCode.java:15

권장 수정안:

-   USER_NOT_IN_GROUP(HttpStatus.NOT_FOUND, "GROUP_003", "그룹에 유저가 존재하지 않습니다.");
+   USER_NOT_IN_GROUP(HttpStatus.FORBIDDEN, "GROUP_003", "해당 그룹의 멤버가 아닙니다.");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
INVALID_MAX_MEMBER(HttpStatus.BAD_REQUEST, "GROUP_001", "최대 인원 수는 1 이상 100 이하여야 합니다."),
USER_NOT_IN_GROUP(HttpStatus.NOT_FOUND, "GROUP_003", "그룹에 유저가 존재하지 않습니다.");
INVALID_MAX_MEMBER(HttpStatus.BAD_REQUEST, "GROUP_001", "최대 인원 수는 1 이상 100 이하여야 합니다."),
USER_NOT_IN_GROUP(HttpStatus.FORBIDDEN, "GROUP_003", "해당 그룹의 멤버가 아닙니다.");
🤖 Prompt for AI Agents
In src/main/java/project/flipnote/group/exception/GroupErrorCode.java around
lines 14-15, change the USER_NOT_IN_GROUP enum entry to use HttpStatus.FORBIDDEN
instead of HttpStatus.NOT_FOUND and update its message to match the
domain/permission wording used by CardSetErrorCode.GROUP_MEMBER_NOT_FOUND (i.e.,
indicate a forbidden/permission-related absence of the user in the group) so
both error codes are consistent.


private final HttpStatus httpStatus;
private final String code;
Expand Down
Loading
Loading