diff --git a/src/main/java/project/flipnote/auth/service/AuthService.java b/src/main/java/project/flipnote/auth/service/AuthService.java index 7bfabfc1..25eec76c 100644 --- a/src/main/java/project/flipnote/auth/service/AuthService.java +++ b/src/main/java/project/flipnote/auth/service/AuthService.java @@ -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; @@ -77,6 +78,8 @@ public UserRegisterResponse register(UserRegisterRequest req) { .build(); userAuthRepository.save(userAuth); + eventPublisher.publishEvent(new UserRegisteredEvent(userId, email)); + return UserRegisterResponse.from(userId); } diff --git a/src/main/java/project/flipnote/common/config/SwaggerConfig.java b/src/main/java/project/flipnote/common/config/SwaggerConfig.java index 57e1f18d..bf7bae7a 100644 --- a/src/main/java/project/flipnote/common/config/SwaggerConfig.java +++ b/src/main/java/project/flipnote/common/config/SwaggerConfig.java @@ -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; @@ -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") diff --git a/src/main/java/project/flipnote/common/event/UserRegisteredEvent.java b/src/main/java/project/flipnote/common/event/UserRegisteredEvent.java new file mode 100644 index 00000000..4ec670c9 --- /dev/null +++ b/src/main/java/project/flipnote/common/event/UserRegisteredEvent.java @@ -0,0 +1,7 @@ +package project.flipnote.common.event; + +public record UserRegisteredEvent( + Long userId, + String email +) { +} diff --git a/src/main/java/project/flipnote/common/response/PageResponse.java b/src/main/java/project/flipnote/common/response/PageResponse.java new file mode 100644 index 00000000..5539602b --- /dev/null +++ b/src/main/java/project/flipnote/common/response/PageResponse.java @@ -0,0 +1,32 @@ +package project.flipnote.common.response; + +import java.util.List; + +import org.springframework.data.domain.Page; + +public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages, + boolean first, + boolean last, + boolean hasNext, + boolean hasPrevious +) { + + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.isFirst(), + page.isLast(), + page.hasNext(), + page.hasPrevious() + ); + } +} diff --git a/src/main/java/project/flipnote/common/security/exception/SecurityErrorCode.java b/src/main/java/project/flipnote/common/security/exception/SecurityErrorCode.java index 149c1de1..cb556b89 100644 --- a/src/main/java/project/flipnote/common/security/exception/SecurityErrorCode.java +++ b/src/main/java/project/flipnote/common/security/exception/SecurityErrorCode.java @@ -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", "권한이 없습니다."); diff --git a/src/main/java/project/flipnote/group/controller/GroupInvitationController.java b/src/main/java/project/flipnote/group/controller/GroupInvitationController.java new file mode 100644 index 00000000..62af5c50 --- /dev/null +++ b/src/main/java/project/flipnote/group/controller/GroupInvitationController.java @@ -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 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 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 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(); + } +} diff --git a/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java b/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java new file mode 100644 index 00000000..19e45079 --- /dev/null +++ b/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java @@ -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> 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 res + = groupInvitationService.getOutgoingInvitations(authPrinciple.userId(), groupId, page, size); + + return ResponseEntity.ok(res); + } + + @GetMapping("/group-invitations") + public ResponseEntity> getIncomingInvitations( + @Min(0) @RequestParam(defaultValue = "0") int page, + @Min(1) @Min(30) @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal AuthPrinciple authPrinciple + ) { + PageResponse res + = groupInvitationService.getIncomingInvitations(authPrinciple.userId(), page, size); + + return ResponseEntity.ok(res); + } +} diff --git a/src/main/java/project/flipnote/group/controller/docs/GroupInvitationControllerDocs.java b/src/main/java/project/flipnote/group/controller/docs/GroupInvitationControllerDocs.java new file mode 100644 index 00000000..8b6c1cfb --- /dev/null +++ b/src/main/java/project/flipnote/group/controller/docs/GroupInvitationControllerDocs.java @@ -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 createGroupInvitation( + Long groupId, GroupInvitationCreateRequest req, AuthPrinciple authPrinciple + ); + + @Operation(summary = "그룹 초대 취소", security = {@SecurityRequirement(name = "access-token")}) + ResponseEntity deleteGroupInvitation(Long groupId, Long invitationId, AuthPrinciple authPrinciple); + + @Operation(summary = "그룹 초대 응답", security = {@SecurityRequirement(name = "access-token")}) + ResponseEntity respondToGroupInvitation( + Long groupId, Long invitationId, GroupInvitationRespondRequest req, AuthPrinciple authPrinciple + ); +} diff --git a/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java b/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java new file mode 100644 index 00000000..72bb4335 --- /dev/null +++ b/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java @@ -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> getOutgoingInvitations( + Long groupId, + int page, + int size, + AuthPrinciple authPrinciple + ); + + @Operation(summary = "그룹 초대 받은 목록 조회", security = {@SecurityRequirement(name = "access-token")}) + ResponseEntity> getIncomingInvitations( + int page, + int size, + AuthPrinciple authPrinciple + ); +} diff --git a/src/main/java/project/flipnote/group/entity/Group.java b/src/main/java/project/flipnote/group/entity/Group.java index bf9551e7..e87aafd2 100644 --- a/src/main/java/project/flipnote/group/entity/Group.java +++ b/src/main/java/project/flipnote/group/entity/Group.java @@ -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) @@ -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); + } + } } diff --git a/src/main/java/project/flipnote/group/entity/GroupInvitation.java b/src/main/java/project/flipnote/group/entity/GroupInvitation.java new file mode 100644 index 00000000..140a4606 --- /dev/null +++ b/src/main/java/project/flipnote/group/entity/GroupInvitation.java @@ -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; + + @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; + } +} diff --git a/src/main/java/project/flipnote/group/entity/GroupInvitationStatus.java b/src/main/java/project/flipnote/group/entity/GroupInvitationStatus.java new file mode 100644 index 00000000..f4a0ffea --- /dev/null +++ b/src/main/java/project/flipnote/group/entity/GroupInvitationStatus.java @@ -0,0 +1,5 @@ +package project.flipnote.group.entity; + +public enum GroupInvitationStatus { + PENDING, ACCEPTED, REJECTED +} diff --git a/src/main/java/project/flipnote/group/exception/GroupErrorCode.java b/src/main/java/project/flipnote/group/exception/GroupErrorCode.java index 3604efe4..5617905c 100644 --- a/src/main/java/project/flipnote/group/exception/GroupErrorCode.java +++ b/src/main/java/project/flipnote/group/exception/GroupErrorCode.java @@ -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", "그룹에 유저가 존재하지 않습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/project/flipnote/group/exception/GroupInvitationErrorCode.java b/src/main/java/project/flipnote/group/exception/GroupInvitationErrorCode.java new file mode 100644 index 00000000..1bca0b37 --- /dev/null +++ b/src/main/java/project/flipnote/group/exception/GroupInvitationErrorCode.java @@ -0,0 +1,27 @@ +package project.flipnote.group.exception; + + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import project.flipnote.common.exception.ErrorCode; + +@Getter +@RequiredArgsConstructor +public enum GroupInvitationErrorCode implements ErrorCode { + ALREADY_INVITED(HttpStatus.CONFLICT, "GROUP_INVITATION_001", "이미 초대된 사용자입니다."), + NO_INVITATION_PERMISSION(HttpStatus.FORBIDDEN, "GROUP_INVITATION_002", "해당 그룹에 초대할 권한이 없습니다."), + INVITATION_NOT_FOUND(HttpStatus.NOT_FOUND, "GROUP_INVITATION_003", "유효하지 않은 초대입니다."), + CANNOT_INVITE_SELF(HttpStatus.BAD_REQUEST, "GROUP_INVITATION_004", "본인을 초대할 수 없습니다."), + ALREADY_GROUP_MEMBER(HttpStatus.CONFLICT, "GROUP_INVITATION_005", "이미 그룹 회원입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public int getStatus() { + return httpStatus.value(); + } +} diff --git a/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java b/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java new file mode 100644 index 00000000..9c6264d7 --- /dev/null +++ b/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java @@ -0,0 +1,37 @@ +package project.flipnote.group.listener; + +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.common.event.UserRegisteredEvent; +import project.flipnote.group.service.GroupInvitationService; + +@Slf4j +@RequiredArgsConstructor +@Component +public class UserRegisteredEventListener { + + private final GroupInvitationService groupInvitationService; + + @Async + @Retryable( + maxAttempts = 3, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void HandleUserRegisteredEvent(UserRegisteredEvent event) { + groupInvitationService.acceptPendingInvitationsOnRegister(event.userId(), event.email()); + } + + @Recover + public void recover(Exception ex, UserRegisteredEvent event) { + log.error("회원가입 후속 처리 예외 발생: userId={}, email={}", event.userId(), event.email(), ex); + } +} diff --git a/src/main/java/project/flipnote/group/model/GroupInvitationCreateRequest.java b/src/main/java/project/flipnote/group/model/GroupInvitationCreateRequest.java new file mode 100644 index 00000000..30a0abf9 --- /dev/null +++ b/src/main/java/project/flipnote/group/model/GroupInvitationCreateRequest.java @@ -0,0 +1,10 @@ +package project.flipnote.group.model; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record GroupInvitationCreateRequest( + @Email @NotBlank + String email +) { +} diff --git a/src/main/java/project/flipnote/group/model/GroupInvitationCreateResponse.java b/src/main/java/project/flipnote/group/model/GroupInvitationCreateResponse.java new file mode 100644 index 00000000..8f98f64e --- /dev/null +++ b/src/main/java/project/flipnote/group/model/GroupInvitationCreateResponse.java @@ -0,0 +1,6 @@ +package project.flipnote.group.model; + +public record GroupInvitationCreateResponse( + Long invitationId +) { +} diff --git a/src/main/java/project/flipnote/group/model/GroupInvitationRespondRequest.java b/src/main/java/project/flipnote/group/model/GroupInvitationRespondRequest.java new file mode 100644 index 00000000..656e6510 --- /dev/null +++ b/src/main/java/project/flipnote/group/model/GroupInvitationRespondRequest.java @@ -0,0 +1,17 @@ +package project.flipnote.group.model; + +import jakarta.validation.constraints.NotNull; +import project.flipnote.group.entity.GroupInvitationStatus; + +public record GroupInvitationRespondRequest( + @NotNull + GroupInvitationResponseStatus status +) { + + public GroupInvitationStatus toEntityStatus() { + return switch (status) { + case ACCEPTED -> GroupInvitationStatus.ACCEPTED; + case REJECTED -> GroupInvitationStatus.REJECTED; + }; + } +} diff --git a/src/main/java/project/flipnote/group/model/GroupInvitationResponseStatus.java b/src/main/java/project/flipnote/group/model/GroupInvitationResponseStatus.java new file mode 100644 index 00000000..f74f710f --- /dev/null +++ b/src/main/java/project/flipnote/group/model/GroupInvitationResponseStatus.java @@ -0,0 +1,5 @@ +package project.flipnote.group.model; + +public enum GroupInvitationResponseStatus { + ACCEPTED, REJECTED; +} diff --git a/src/main/java/project/flipnote/group/model/GroupInvitationStatus.java b/src/main/java/project/flipnote/group/model/GroupInvitationStatus.java new file mode 100644 index 00000000..07736e1d --- /dev/null +++ b/src/main/java/project/flipnote/group/model/GroupInvitationStatus.java @@ -0,0 +1,18 @@ +package project.flipnote.group.model; + +public enum GroupInvitationStatus { + PENDING, ACCEPTED, REJECTED; + + public static GroupInvitationStatus from(project.flipnote.group.entity.GroupInvitationStatus status) { + if (status == null) { + throw new IllegalArgumentException("GroupInvitationStatus is null"); + } + + return switch (status) { + case PENDING -> PENDING; + case ACCEPTED -> ACCEPTED; + case REJECTED -> REJECTED; + default -> throw new IllegalArgumentException("Unknown GroupInvitationStatus: " + status); + }; + } +} diff --git a/src/main/java/project/flipnote/group/model/IncomingGroupInvitationResponse.java b/src/main/java/project/flipnote/group/model/IncomingGroupInvitationResponse.java new file mode 100644 index 00000000..703ae0c9 --- /dev/null +++ b/src/main/java/project/flipnote/group/model/IncomingGroupInvitationResponse.java @@ -0,0 +1,26 @@ +package project.flipnote.group.model; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import project.flipnote.group.entity.GroupInvitation; + +public record IncomingGroupInvitationResponse( + Long invitationId, + Long groupId, + GroupInvitationStatus status, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt +) { + + public static IncomingGroupInvitationResponse from(GroupInvitation invitation) { + return new IncomingGroupInvitationResponse( + invitation.getId(), + invitation.getGroup().getId(), + GroupInvitationStatus.from(invitation.getStatus()), + invitation.getCreatedAt() + ); + } +} diff --git a/src/main/java/project/flipnote/group/model/OutgoingGroupInvitationResponse.java b/src/main/java/project/flipnote/group/model/OutgoingGroupInvitationResponse.java new file mode 100644 index 00000000..43ee3eeb --- /dev/null +++ b/src/main/java/project/flipnote/group/model/OutgoingGroupInvitationResponse.java @@ -0,0 +1,32 @@ +package project.flipnote.group.model; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import project.flipnote.group.entity.GroupInvitation; + +public record OutgoingGroupInvitationResponse( + Long invitationId, + Long inviterUserId, + Long inviteeUserId, + String inviteeEmail, + String inviteeNickname, + GroupInvitationStatus status, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt +) { + + public static OutgoingGroupInvitationResponse from(GroupInvitation invitation, String inviteeNickname) { + return new OutgoingGroupInvitationResponse( + invitation.getId(), + invitation.getInviterUserId(), + invitation.getInviteeUserId(), + invitation.getInviteeEmail(), + inviteeNickname, + GroupInvitationStatus.from(invitation.getStatus()), + invitation.getCreatedAt() + ); + } +} diff --git a/src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java b/src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java new file mode 100644 index 00000000..8bff8219 --- /dev/null +++ b/src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java @@ -0,0 +1,53 @@ +package project.flipnote.group.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import project.flipnote.group.entity.GroupInvitation; +import project.flipnote.group.entity.GroupInvitationStatus; + +public interface GroupInvitationRepository extends JpaRepository { + + Optional findByIdAndStatus(Long id, GroupInvitationStatus status); + + @Query(""" + SELECT gi + FROM GroupInvitation gi + JOIN FETCH gi.group g + WHERE gi.id = :id + AND g.id = :groupId + AND gi.inviteeUserId = :inviteeUserId + AND gi.status = :status + """) + Optional findWithGroupByIdAndGroup_IdAndInviteeUserIdAndStatus( + @Param("id") Long id, + @Param("groupId") Long groupId, + @Param("inviteeUserId") Long inviteeUserId, + @Param("status") GroupInvitationStatus status + ); + + Page findAllByGroup_Id(Long groupId, Pageable pageable); + + @Query(""" + SELECT gi + FROM GroupInvitation gi + JOIN FETCH gi.group g + WHERE gi.inviteeEmail = :inviteeEmail + AND gi.status = :status + """) + List findAllWithGroupByInviteeEmailAndStatus( + @Param("inviteeEmail") String inviteeEmail, + @Param("status") GroupInvitationStatus status + ); + Page findAllByInviteeUserId(Long inviteeUserId, Pageable pageable); + + boolean existsByGroup_IdAndInviteeUserIdAndStatus(Long groupId, Long inviteeUserId, GroupInvitationStatus status); + + boolean existsByGroup_IdAndInviteeEmailAndStatus(Long groupId, String inviteeEmail, GroupInvitationStatus status); +} diff --git a/src/main/java/project/flipnote/group/repository/GroupMemberRepository.java b/src/main/java/project/flipnote/group/repository/GroupMemberRepository.java index c000389f..c5001b75 100644 --- a/src/main/java/project/flipnote/group/repository/GroupMemberRepository.java +++ b/src/main/java/project/flipnote/group/repository/GroupMemberRepository.java @@ -17,4 +17,7 @@ public interface GroupMemberRepository extends JpaRepository long countByGroup_Id(Long groupId); boolean existsByGroup_idAndUser_id(Long groupId, Long userId); + + Optional findByGroup_IdAndUser_Id(Long groupId, Long userId); + } diff --git a/src/main/java/project/flipnote/group/service/GroupInvitationService.java b/src/main/java/project/flipnote/group/service/GroupInvitationService.java new file mode 100644 index 00000000..884f4e59 --- /dev/null +++ b/src/main/java/project/flipnote/group/service/GroupInvitationService.java @@ -0,0 +1,292 @@ +package project.flipnote.group.service; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import project.flipnote.common.exception.BizException; +import project.flipnote.common.response.PageResponse; +import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.group.entity.Group; +import project.flipnote.group.entity.GroupInvitation; +import project.flipnote.group.entity.GroupInvitationStatus; +import project.flipnote.group.entity.GroupMember; +import project.flipnote.group.entity.GroupMemberRole; +import project.flipnote.group.entity.GroupPermissionStatus; +import project.flipnote.group.exception.GroupInvitationErrorCode; +import project.flipnote.group.model.GroupInvitationCreateRequest; +import project.flipnote.group.model.GroupInvitationCreateResponse; +import project.flipnote.group.model.GroupInvitationRespondRequest; +import project.flipnote.group.model.IncomingGroupInvitationResponse; +import project.flipnote.group.model.OutgoingGroupInvitationResponse; +import project.flipnote.group.repository.GroupInvitationRepository; +import project.flipnote.group.repository.GroupMemberRepository; +import project.flipnote.group.repository.GroupRepository; +import project.flipnote.user.entity.UserProfile; +import project.flipnote.user.service.UserService; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class GroupInvitationService { + + private final UserService userService; + private final GroupInvitationRepository groupInvitationRepository; + private final GroupService groupService; + private final GroupRepository groupRepository; + private final GroupMemberRepository groupMemberRepository; + private final EntityManager em; + + /** + * 그룹에 회원 혹은 비회원 초대 + * + * @param authPrinciple 초대한 회원 인증 정보 + * @param groupId 초대한 그룹 ID + * @param req 초대 대상 정보 + * @author 윤정환 + */ + @Transactional + public GroupInvitationCreateResponse createGroupInvitation(AuthPrinciple authPrinciple, Long groupId, + GroupInvitationCreateRequest req) { + Long inviterUserId = authPrinciple.userId(); + validateGroupInvitePermission(inviterUserId, groupId); + + String inviterUserEmail = authPrinciple.email(); + String inviteeEmail = req.email(); + if (Objects.equals(inviterUserEmail, inviteeEmail)) { + throw new BizException(GroupInvitationErrorCode.CANNOT_INVITE_SELF); + } + + Long invitationId = userService.findActiveUserByEmail(inviteeEmail) + .map(inviteeUser -> createUserInvitation(inviterUserId, groupId, inviteeUser)) + .orElseGet(() -> createGuestInvitation(inviterUserId, groupId, inviteeEmail)); + + return new GroupInvitationCreateResponse(invitationId); + } + + /** + * 그룹 초대를 취소 + * + * @param userId 초대를 취소하는 회원 ID + * @param groupId 초대한 그룹 ID + * @param invitationId 취소할 초대의 ID + * @author 윤정환 + */ + @Transactional + public void deleteGroupInvitation(Long userId, Long groupId, Long invitationId) { + validateGroupInvitePermission(userId, groupId); + + GroupInvitation invitation = groupInvitationRepository + .findByIdAndStatus(invitationId, GroupInvitationStatus.PENDING) + .orElseThrow(() -> new BizException(GroupInvitationErrorCode.INVITATION_NOT_FOUND)); + + groupInvitationRepository.delete(invitation); + } + + /** + * 그룹 초대에 응답 + * + * @param inviteeUserId 초대를 받은 회원 ID + * @param groupId 초대한 그룹 ID + * @param invitationId 응답할 초대의 ID + * @param req 초대에 응답할 정보 + * @author 윤정환 + */ + @Transactional + public void respondToGroupInvitation( + Long inviteeUserId, + Long groupId, + Long invitationId, + GroupInvitationRespondRequest req + ) { + GroupInvitation invitation = groupInvitationRepository.findWithGroupByIdAndGroup_IdAndInviteeUserIdAndStatus( + invitationId, groupId, inviteeUserId, GroupInvitationStatus.PENDING + ) + .orElseThrow(() -> new BizException(GroupInvitationErrorCode.INVITATION_NOT_FOUND)); + + invitation.getGroup().validateJoinable(); + + invitation.respond(req.toEntityStatus()); + + if (Objects.equals(invitation.getStatus(), GroupInvitationStatus.ACCEPTED)) { + addGroupMember(inviteeUserId, groupId); + } + } + + /** + * 그룹 초대 보낸 목록을 페이징하여 조회 + * + * @param userId 초대 보낸 목록을 조회하는 회원 ID + * @param groupId 초대한 그룹 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return 페이징된 그룹 초대 보낸 목록 응답 + * @author 윤정환 + */ + public PageResponse getOutgoingInvitations(Long userId, Long groupId, int page, + int size) { + if (!groupService.hasPermission(groupId, userId, GroupPermissionStatus.INVITE)) { + throw new BizException(GroupInvitationErrorCode.NO_INVITATION_PERMISSION); + } + + // TODO: Projection 및 카운트 쿼리 튜닝 필요 + PageRequest pageRequest = PageRequest.of(page, size); + Page invitationPage = groupInvitationRepository.findAllByGroup_Id(groupId, pageRequest); + + List inviteeUserIds = invitationPage.getContent() + .stream() + .map(GroupInvitation::getInviteeUserId) + .filter(Objects::nonNull) + .toList(); + Map idAndNicknames = userService.getIdAndNicknames(inviteeUserIds); + + Page res = invitationPage.map( + (invitation) -> OutgoingGroupInvitationResponse.from( + invitation, idAndNicknames.getOrDefault(invitation.getInviteeUserId(), "") + ) + ); + + return PageResponse.from(res); + } + + /** + * 그룹 초대 받은 목록을 페이징하여 조회 + * + * @param userId 초대 받은 목록을 조회하는 회원 ID + * @param page 페이지 번호 + * @param size 페이지 크기 + * @return 페이징된 그룹 초대 받은 목록 응답 + * @author 윤정환 + */ + public PageResponse getIncomingInvitations(Long userId, int page, int size) { + // TODO: Projection 및 카운트 쿼리 튜닝 필요 + PageRequest pageRequest = PageRequest.of(page, size); + Page invitationPage = groupInvitationRepository.findAllByInviteeUserId(userId, pageRequest); + + Page res = invitationPage.map(IncomingGroupInvitationResponse::from); + return PageResponse.from(res); + } + + /** + * 회원가입시 비회원 그룹 초대를 수락 + * + * @param inviteeUserId 초대 받은 회원 ID + * @param inviteeEmail 초대 받은 회원 email + * @author 윤정환 + */ + @Transactional + public void acceptPendingInvitationsOnRegister(Long inviteeUserId, String inviteeEmail) { + List invitations = groupInvitationRepository + .findAllWithGroupByInviteeEmailAndStatus(inviteeEmail, GroupInvitationStatus.PENDING); + + for (GroupInvitation invitation : invitations) { + Group group = invitation.getGroup(); + + group.validateJoinable(); + + invitation.respond(GroupInvitationStatus.ACCEPTED); + + addGroupMember(inviteeUserId, group.getId()); + } + } + + /** + * 그룹 초대 권한을 검증 + * + * @param userId 권한을 검증할 회원 ID + * @param groupId 검증할 그룹 ID + * @author 윤정환 + */ + private void validateGroupInvitePermission(Long userId, Long groupId) { + if (!groupService.hasPermission(groupId, userId, GroupPermissionStatus.INVITE)) { + throw new BizException(GroupInvitationErrorCode.NO_INVITATION_PERMISSION); + } + } + + /** + * 그룹에 회원 초대 + * + * @param inviterUserId 초대한 회원 ID + * @param groupId 초대한 그룹 ID + * @param inviteeUser 초대 받는 user + * @author 윤정환 + */ + private Long createUserInvitation(Long inviterUserId, Long groupId, UserProfile inviteeUser) { + if (groupMemberRepository.existsByGroup_idAndUser_id(groupId, inviteeUser.getId())) { + throw new BizException(GroupInvitationErrorCode.ALREADY_GROUP_MEMBER); + } + + if (groupInvitationRepository + .existsByGroup_IdAndInviteeUserIdAndStatus(groupId, inviteeUser.getId(), GroupInvitationStatus.PENDING) + ) { + throw new BizException(GroupInvitationErrorCode.ALREADY_INVITED); + } + + GroupInvitation invitation = GroupInvitation.builder() + .group(groupRepository.getReferenceById(groupId)) + .inviterUserId(inviterUserId) + .inviteeUserId(inviteeUser.getId()) + .inviteeEmail(inviteeUser.getEmail()) + .build(); + groupInvitationRepository.save(invitation); + + // TODO: 초대받은 회원한테 알림 전송 + + return invitation.getId(); + } + + /** + * 그룹에 비회원 초대 + * + * @param inviterUserId 초대한 회원 ID + * @param groupId 초대한 그룹 ID + * @param inviteeEmail 초대 받는 비회원 email + * @author 윤정환 + */ + private Long createGuestInvitation(Long inviterUserId, Long groupId, String inviteeEmail) { + if (groupInvitationRepository + .existsByGroup_IdAndInviteeEmailAndStatus(groupId, inviteeEmail, GroupInvitationStatus.PENDING) + ) { + throw new BizException(GroupInvitationErrorCode.ALREADY_INVITED); + } + + GroupInvitation invitation = GroupInvitation.builder() + .group(groupRepository.getReferenceById(groupId)) + .inviterUserId(inviterUserId) + .inviteeEmail(inviteeEmail) + .build(); + groupInvitationRepository.save(invitation); + + // TODO: 초대받은 비회원한테 이메일 전송 + + return invitation.getId(); + } + + /** + * 그룹에 회원 추가 + * + * @param inviteeUserId 초대 받는 회원 ID + * @param groupId 초대한 그룹 ID + * @author 윤정환 + */ + private void addGroupMember(Long inviteeUserId, Long groupId) { + if (groupMemberRepository.existsByGroup_idAndUser_id(groupId, inviteeUserId)) { + return; + } + + GroupMember groupMember = GroupMember.builder() + .group(groupRepository.getReferenceById(groupId)) + .user(em.getReference(UserProfile.class, inviteeUserId)) + .role(GroupMemberRole.MEMBER) + .build(); + + groupMemberRepository.save(groupMember); + } +} diff --git a/src/main/java/project/flipnote/group/service/GroupService.java b/src/main/java/project/flipnote/group/service/GroupService.java index eba053c5..4e7049ac 100644 --- a/src/main/java/project/flipnote/group/service/GroupService.java +++ b/src/main/java/project/flipnote/group/service/GroupService.java @@ -14,6 +14,7 @@ import project.flipnote.group.entity.GroupMember; import project.flipnote.group.entity.GroupMemberRole; import project.flipnote.group.entity.GroupPermission; +import project.flipnote.group.entity.GroupPermissionStatus; import project.flipnote.group.entity.GroupRolePermission; import project.flipnote.group.exception.GroupErrorCode; import project.flipnote.group.model.GroupCreateRequest; @@ -68,6 +69,20 @@ public GroupCreateResponse create(AuthPrinciple authPrinciple, GroupCreateReques return GroupCreateResponse.from(group.getId()); } + public Boolean hasPermission(Long groupId, Long userId, GroupPermissionStatus groupPermissionStatus) { + GroupMember groupMember = groupMemberRepository.findByGroup_IdAndUser_Id(groupId, userId).orElseThrow( + () -> new BizException(GroupErrorCode.USER_NOT_IN_GROUP) + ); + + GroupPermission groupPermission = groupPermissionRepository.findByName(groupPermissionStatus); + + return groupRolePermissionRepository.existsByGroupAndRoleAndGroupPermission( + groupRepository.getReferenceById(groupId), + groupMember.getRole(), + groupPermission + ); + } + /* 최초 그룹 권한 설정 */ diff --git a/src/main/java/project/flipnote/user/model/UserIdNickname.java b/src/main/java/project/flipnote/user/model/UserIdNickname.java new file mode 100644 index 00000000..d7bf56a3 --- /dev/null +++ b/src/main/java/project/flipnote/user/model/UserIdNickname.java @@ -0,0 +1,6 @@ +package project.flipnote.user.model; + +public interface UserIdNickname { + Long getId(); + String getNickname(); +} diff --git a/src/main/java/project/flipnote/user/repository/UserProfileRepository.java b/src/main/java/project/flipnote/user/repository/UserProfileRepository.java index 19eb4141..7c6370c3 100644 --- a/src/main/java/project/flipnote/user/repository/UserProfileRepository.java +++ b/src/main/java/project/flipnote/user/repository/UserProfileRepository.java @@ -1,9 +1,11 @@ package project.flipnote.user.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import project.flipnote.user.model.UserIdNickname; import project.flipnote.user.entity.UserProfile; import project.flipnote.user.entity.UserStatus; @@ -14,4 +16,8 @@ public interface UserProfileRepository extends JpaRepository boolean existsByPhone(String phone); Optional findByIdAndStatus(Long userId, UserStatus status); + + Optional findByEmailAndStatus(String email, UserStatus status); + + List findIdAndNicknameByIdIn(List ids); } diff --git a/src/main/java/project/flipnote/user/service/UserService.java b/src/main/java/project/flipnote/user/service/UserService.java index 0cc8e713..de001b60 100644 --- a/src/main/java/project/flipnote/user/service/UserService.java +++ b/src/main/java/project/flipnote/user/service/UserService.java @@ -1,6 +1,10 @@ package project.flipnote.user.service; +import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -10,6 +14,7 @@ import project.flipnote.common.dto.UserCreateCommand; import project.flipnote.common.event.UserWithdrawnEvent; import project.flipnote.common.exception.BizException; +import project.flipnote.user.model.UserIdNickname; import project.flipnote.user.entity.UserProfile; import project.flipnote.user.entity.UserStatus; import project.flipnote.user.exception.UserErrorCode; @@ -47,7 +52,7 @@ public Long createUser(UserCreateCommand command) { @Transactional public void withdraw(Long userId) { - UserProfile user = findActiveUserById(userId); + UserProfile user = findActiveUserByIdOrThrow(userId); user.withdraw(); eventPublisher.publishEvent(new UserWithdrawnEvent(userId)); @@ -55,7 +60,7 @@ public void withdraw(Long userId) { @Transactional public UserUpdateResponse update(Long userId, UserUpdateRequest req) { - UserProfile user = findActiveUserById(userId); + UserProfile user = findActiveUserByIdOrThrow(userId); String phone = req.getNormalizedPhone(); if (!Objects.equals(user.getPhone(), phone)) { @@ -68,22 +73,26 @@ public UserUpdateResponse update(Long userId, UserUpdateRequest req) { } public MyInfoResponse getMyInfo(Long userId) { - UserProfile user = findActiveUserById(userId); + UserProfile user = findActiveUserByIdOrThrow(userId); return MyInfoResponse.from(user); } public UserInfoResponse getUserInfo(Long userId) { - UserProfile user = findActiveUserById(userId); + UserProfile user = findActiveUserByIdOrThrow(userId); return UserInfoResponse.from(user); } - private UserProfile findActiveUserById(Long userId) { + private UserProfile findActiveUserByIdOrThrow(Long userId) { return userProfileRepository.findByIdAndStatus(userId, UserStatus.ACTIVE) .orElseThrow(() -> new BizException(UserErrorCode.USER_NOT_FOUND)); } + public Optional findActiveUserByEmail(String email) { + return userProfileRepository.findByEmailAndStatus(email, UserStatus.ACTIVE); + } + private void validateEmailDuplicate(String email) { if (userProfileRepository.existsByEmail(email)) { throw new BizException(UserErrorCode.DUPLICATE_EMAIL); @@ -99,4 +108,19 @@ public void validatePhoneDuplicate(String phone) { throw new BizException(UserErrorCode.DUPLICATE_PHONE); } } + + public Map getIdAndNicknames(List inviteeUserIds) { + if (inviteeUserIds == null || inviteeUserIds.isEmpty()) { + return java.util.Collections.emptyMap(); + } + + List distinctIds = inviteeUserIds.stream().distinct().toList(); + List idAndNicknames = userProfileRepository.findIdAndNicknameByIdIn(distinctIds); + return idAndNicknames.stream() + .collect(Collectors.toMap( + UserIdNickname::getId, + UserIdNickname::getNickname, + (a, b) -> a + )); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d2cc7d20..9e9faa2d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -89,3 +89,7 @@ cloud: scope: - email - profile + +springdoc: + server: + url: http://localhost:8080