diff --git a/src/main/java/project/flipnote/auth/service/AuthService.java b/src/main/java/project/flipnote/auth/service/AuthService.java index a25e5049..d01db712 100644 --- a/src/main/java/project/flipnote/auth/service/AuthService.java +++ b/src/main/java/project/flipnote/auth/service/AuthService.java @@ -78,7 +78,7 @@ public UserRegisterResponse register(UserRegisterRequest req) { .build(); userAuthRepository.save(userAuth); - eventPublisher.publishEvent(new UserRegisteredEvent(email)); + eventPublisher.publishEvent(new UserRegisteredEvent(email, userId)); return UserRegisterResponse.from(userId); } diff --git a/src/main/java/project/flipnote/cardset/controller/CardSetController.java b/src/main/java/project/flipnote/cardset/controller/CardSetController.java index 88e0c0fd..9464a6c3 100644 --- a/src/main/java/project/flipnote/cardset/controller/CardSetController.java +++ b/src/main/java/project/flipnote/cardset/controller/CardSetController.java @@ -1,36 +1,32 @@ package project.flipnote.cardset.controller; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -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.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import project.flipnote.cardset.model.CreateCardSetRequest; -import project.flipnote.cardset.model.CreateCardSetResponse; +import project.flipnote.cardset.controller.docs.CardSetControllerDocs; +import project.flipnote.cardset.model.CardSetSearchRequest; +import project.flipnote.cardset.model.CardSetSummaryResponse; import project.flipnote.cardset.service.CardSetService; -import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.common.model.response.PagingResponse; @RequiredArgsConstructor @RestController -@RequestMapping("/v1/groups/{groupId}/card-sets") -public class CardSetController { +@RequestMapping("/v1/card-sets") +public class CardSetController implements CardSetControllerDocs { private final CardSetService cardSetService; - @PostMapping("") - public ResponseEntity createCardSet( - @AuthenticationPrincipal AuthPrinciple authPrinciple, - @PathVariable("groupId") Long groupId, - @RequestBody @Valid CreateCardSetRequest req + @GetMapping + public ResponseEntity> getCardSets( + @Valid @ModelAttribute CardSetSearchRequest req ) { - CreateCardSetResponse res = cardSetService.createCardSet(groupId, authPrinciple, req); + PagingResponse res = cardSetService.getCardSets(req); - return ResponseEntity.status(HttpStatus.CREATED).body(res); + return ResponseEntity.ok(res); } } diff --git a/src/main/java/project/flipnote/cardset/controller/GroupCardSetController.java b/src/main/java/project/flipnote/cardset/controller/GroupCardSetController.java new file mode 100644 index 00000000..7e4c91f4 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/controller/GroupCardSetController.java @@ -0,0 +1,65 @@ +package project.flipnote.cardset.controller; + +import org.springframework.http.HttpStatus; +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.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import project.flipnote.cardset.controller.docs.GroupCardSetControllerDocs; +import project.flipnote.cardset.model.CardSetDetailResponse; +import project.flipnote.cardset.model.CardSetUpdateRequest; +import project.flipnote.cardset.model.CreateCardSetRequest; +import project.flipnote.cardset.model.CreateCardSetResponse; +import project.flipnote.cardset.service.CardSetService; +import project.flipnote.common.security.dto.AuthPrinciple; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/v1/groups/{groupId}/card-sets") +public class GroupCardSetController implements GroupCardSetControllerDocs { + + private final CardSetService cardSetService; + + @PostMapping("") + public ResponseEntity createCardSet( + @AuthenticationPrincipal AuthPrinciple authPrinciple, + @PathVariable("groupId") Long groupId, + @RequestBody @Valid CreateCardSetRequest req + ) { + CreateCardSetResponse res = cardSetService.createCardSet(groupId, authPrinciple, req); + + return ResponseEntity.status(HttpStatus.CREATED).body(res); + } + + @GetMapping("/{cardSetId}") + public ResponseEntity getCardSet( + @PathVariable("groupId") Long groupId, + @PathVariable("cardSetId") Long cardSetId, + @AuthenticationPrincipal AuthPrinciple authPrinciple + ) { + CardSetDetailResponse res = cardSetService.getCardSet(authPrinciple.userId(), groupId, cardSetId); + + return ResponseEntity.ok(res); + } + + @PutMapping("/{cardSetId}") + public ResponseEntity updateCardSet( + @PathVariable("groupId") Long groupId, + @PathVariable("cardSetId") Long cardSetId, + @Valid @RequestBody CardSetUpdateRequest req, + @AuthenticationPrincipal AuthPrinciple authPrinciple + ) { + CardSetDetailResponse res = cardSetService.updateCardSet(authPrinciple.userId(), groupId, cardSetId, req); + + return ResponseEntity.ok(res); + } + +} diff --git a/src/main/java/project/flipnote/cardset/controller/docs/CardSetControllerDocs.java b/src/main/java/project/flipnote/cardset/controller/docs/CardSetControllerDocs.java new file mode 100644 index 00000000..81ed5ac3 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/controller/docs/CardSetControllerDocs.java @@ -0,0 +1,19 @@ +package project.flipnote.cardset.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.cardset.model.CardSetSearchRequest; +import project.flipnote.cardset.model.CardSetSummaryResponse; +import project.flipnote.common.model.response.PagingResponse; + +@Tag(name = "CardSet", description = "CardSet API") +public interface CardSetControllerDocs { + + @Operation(summary = "카드셋 목록 조회(검색)", security = {@SecurityRequirement(name = "access-token")}) + ResponseEntity> getCardSets( + CardSetSearchRequest req + ); +} diff --git a/src/main/java/project/flipnote/cardset/controller/docs/GroupCardSetControllerDocs.java b/src/main/java/project/flipnote/cardset/controller/docs/GroupCardSetControllerDocs.java new file mode 100644 index 00000000..0b450cc4 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/controller/docs/GroupCardSetControllerDocs.java @@ -0,0 +1,32 @@ +package project.flipnote.cardset.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.cardset.model.CardSetDetailResponse; +import project.flipnote.cardset.model.CardSetUpdateRequest; +import project.flipnote.cardset.model.CreateCardSetRequest; +import project.flipnote.cardset.model.CreateCardSetResponse; +import project.flipnote.common.security.dto.AuthPrinciple; + +@Tag(name = "CardSet", description = "CardSet API") +public interface GroupCardSetControllerDocs { + + @Operation(summary = "카드셋 생성", security = {@SecurityRequirement(name = "access-token")}) + ResponseEntity createCardSet( + AuthPrinciple authPrinciple, Long groupId, CreateCardSetRequest req + ); + + @Operation(summary = "카드셋 상세 조회", security = {@SecurityRequirement(name = "access-token")}) + ResponseEntity getCardSet(Long groupId, Long cardSetId, AuthPrinciple authPrinciple); + + @Operation(summary = "카드셋 수정", security = {@SecurityRequirement(name = "access-token")}) + ResponseEntity updateCardSet( + Long groupId, + Long cardSetId, + CardSetUpdateRequest req, + AuthPrinciple authPrinciple + ); +} diff --git a/src/main/java/project/flipnote/cardset/entity/CardSet.java b/src/main/java/project/flipnote/cardset/entity/CardSet.java index 3e4ac665..37b13020 100644 --- a/src/main/java/project/flipnote/cardset/entity/CardSet.java +++ b/src/main/java/project/flipnote/cardset/entity/CardSet.java @@ -14,6 +14,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import project.flipnote.cardset.model.CardSetUpdatePayload; import project.flipnote.common.entity.BaseEntity; import project.flipnote.group.entity.Category; import project.flipnote.group.entity.Group; @@ -57,4 +58,12 @@ private CardSet(String name, Group group, Boolean publicVisible, Category catego this.hashtag = hashtag; this.imageUrl = imageUrl; } + + public void update(CardSetUpdatePayload payload) { + this.name = payload.name(); + this.publicVisible = payload.publicVisible(); + this.category = payload.category(); + this.hashtag = payload.hashtag(); + this.imageUrl = payload.imageUrl(); + } } diff --git a/src/main/java/project/flipnote/cardset/exception/CardSetErrorCode.java b/src/main/java/project/flipnote/cardset/exception/CardSetErrorCode.java index 2ebcf229..4b60fd4a 100644 --- a/src/main/java/project/flipnote/cardset/exception/CardSetErrorCode.java +++ b/src/main/java/project/flipnote/cardset/exception/CardSetErrorCode.java @@ -10,7 +10,10 @@ @RequiredArgsConstructor public enum CardSetErrorCode implements ErrorCode { - GROUP_MEMBER_NOT_FOUND(HttpStatus.FORBIDDEN, "CARDSET_001", "해당 그룹의 멤버가 아닙니다."); + GROUP_MEMBER_NOT_FOUND(HttpStatus.FORBIDDEN, "CARDSET_001", "해당 그룹의 멤버가 아닙니다."), + CARD_SET_NOT_FOUND(HttpStatus.NOT_FOUND, "CARDSET_002", "카드셋이 존재하지 않습니다."), + CARD_SET_PRIVATE(HttpStatus.FORBIDDEN, "CARDSET_003", "비공개 카드셋입니다."), + CARD_SET_NO_EDIT_PERMISSION(HttpStatus.FORBIDDEN, "CARDSET_004", "카드셋 수정 권한이 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/project/flipnote/cardset/model/CardSetDetailResponse.java b/src/main/java/project/flipnote/cardset/model/CardSetDetailResponse.java new file mode 100644 index 00000000..4a7ed142 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/model/CardSetDetailResponse.java @@ -0,0 +1,38 @@ +package project.flipnote.cardset.model; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import project.flipnote.cardset.entity.CardSet; + +public record CardSetDetailResponse( + Long cardSetId, + Long groupId, + String name, + String category, + String hashtag, + String imageUrl, + boolean publicVisible, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime modifiedAt +) { + + public static CardSetDetailResponse from(CardSet cardSet) { + return new CardSetDetailResponse( + cardSet.getId(), + cardSet.getGroup().getId(), + cardSet.getName(), + cardSet.getCategory().name(), + cardSet.getHashtag(), + cardSet.getImageUrl(), + cardSet.getPublicVisible(), + cardSet.getCreatedAt(), + cardSet.getModifiedAt() + ); + } +} diff --git a/src/main/java/project/flipnote/cardset/model/CardSetSearchRequest.java b/src/main/java/project/flipnote/cardset/model/CardSetSearchRequest.java new file mode 100644 index 00000000..a2450404 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/model/CardSetSearchRequest.java @@ -0,0 +1,35 @@ +package project.flipnote.cardset.model; + +import java.util.Set; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +import lombok.Getter; +import lombok.Setter; +import project.flipnote.common.model.request.PagingRequest; + +@Getter +@Setter +public class CardSetSearchRequest extends PagingRequest { + + private static final Set ALLOWED_SORT_FIELDS = Set.of("id"); + + private String keyword; + private String category; + + @Override + public PageRequest getPageRequest() { + String sortBy = this.getSortBy(); + String effectiveSortBy = (sortBy != null && ALLOWED_SORT_FIELDS.contains(sortBy)) ? sortBy : "id"; + + Sort.Direction direction; + try { + direction = Sort.Direction.fromString(this.getOrder()); + } catch (IllegalArgumentException e) { + direction = Sort.Direction.DESC; + } + + return PageRequest.of(getPage() - 1, getSize() + 1, Sort.by(direction, effectiveSortBy)); + } +} diff --git a/src/main/java/project/flipnote/cardset/model/CardSetSummaryResponse.java b/src/main/java/project/flipnote/cardset/model/CardSetSummaryResponse.java new file mode 100644 index 00000000..e3c44689 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/model/CardSetSummaryResponse.java @@ -0,0 +1,24 @@ +package project.flipnote.cardset.model; + +import project.flipnote.cardset.entity.CardSet; + +public record CardSetSummaryResponse( + Long cardSetId, + Long groupId, + String name, + String category, + String hashtag, + String imageUrl +) { + + public static CardSetSummaryResponse from(CardSet cardSet) { + return new CardSetSummaryResponse( + cardSet.getId(), + cardSet.getGroup().getId(), + cardSet.getName(), + cardSet.getCategory().name(), + cardSet.getHashtag(), + cardSet.getImageUrl() + ); + } +} diff --git a/src/main/java/project/flipnote/cardset/model/CardSetUpdatePayload.java b/src/main/java/project/flipnote/cardset/model/CardSetUpdatePayload.java new file mode 100644 index 00000000..f38330c7 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/model/CardSetUpdatePayload.java @@ -0,0 +1,22 @@ +package project.flipnote.cardset.model; + +import project.flipnote.group.entity.Category; + +public record CardSetUpdatePayload( + String name, + Boolean publicVisible, + Category category, + String hashtag, + String imageUrl +) { + + public static CardSetUpdatePayload from(CardSetUpdateRequest req) { + return new CardSetUpdatePayload( + req.name(), + req.publicVisible(), + req.category(), + req.getHashTag(), + req.image() + ); + } +} diff --git a/src/main/java/project/flipnote/cardset/model/CardSetUpdateRequest.java b/src/main/java/project/flipnote/cardset/model/CardSetUpdateRequest.java new file mode 100644 index 00000000..551eae86 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/model/CardSetUpdateRequest.java @@ -0,0 +1,36 @@ +package project.flipnote.cardset.model; + +import java.util.List; + +import org.hibernate.validator.constraints.URL; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import project.flipnote.group.entity.Category; + +public record CardSetUpdateRequest( + + @NotBlank + @Size(max = 50) + String name, + + @NotNull + Boolean publicVisible, + + @NotNull + Category category, + + @NotNull + List hashtag, + + @URL + String image +) { + + @Schema(hidden = true) + public String getHashTag() { + return hashtag != null && !hashtag.isEmpty() ? String.join(",", hashtag) : null; + } +} diff --git a/src/main/java/project/flipnote/cardset/repository/CardSetManagerRepository.java b/src/main/java/project/flipnote/cardset/repository/CardSetManagerRepository.java index 429eb960..02367726 100644 --- a/src/main/java/project/flipnote/cardset/repository/CardSetManagerRepository.java +++ b/src/main/java/project/flipnote/cardset/repository/CardSetManagerRepository.java @@ -7,4 +7,6 @@ @Repository public interface CardSetManagerRepository extends JpaRepository { + + boolean existsByUser_IdAndCardSet_Id(Long userId, Long cardSetId); } diff --git a/src/main/java/project/flipnote/cardset/repository/CardSetRepository.java b/src/main/java/project/flipnote/cardset/repository/CardSetRepository.java index fd04f29c..42a30727 100644 --- a/src/main/java/project/flipnote/cardset/repository/CardSetRepository.java +++ b/src/main/java/project/flipnote/cardset/repository/CardSetRepository.java @@ -1,10 +1,32 @@ package project.flipnote.cardset.repository; +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 org.springframework.stereotype.Repository; import project.flipnote.cardset.entity.CardSet; +import project.flipnote.group.entity.Category; @Repository public interface CardSetRepository extends JpaRepository { + + @Query(""" + SELECT c FROM CardSet c + WHERE (:name IS NULL OR c.name LIKE CONCAT('%', :name, '%')) + AND (:category IS NULL OR c.category = :category) + AND c.publicVisible = TRUE + """) + Page findByNameContainingAndCategory( + @Param("name") String name, + @Param("category") Category category, + Pageable pageable + ); + + Optional findByIdAndGroup_Id(Long id, Long groupId); + } diff --git a/src/main/java/project/flipnote/cardset/service/CardSetPolicyService.java b/src/main/java/project/flipnote/cardset/service/CardSetPolicyService.java new file mode 100644 index 00000000..406d4cf2 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/service/CardSetPolicyService.java @@ -0,0 +1,60 @@ +package project.flipnote.cardset.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import project.flipnote.cardset.entity.CardSet; +import project.flipnote.cardset.exception.CardSetErrorCode; +import project.flipnote.cardset.repository.CardSetManagerRepository; +import project.flipnote.cardset.repository.CardSetRepository; +import project.flipnote.common.exception.BizException; +import project.flipnote.group.service.GroupService; + +@RequiredArgsConstructor +@Service +public class CardSetPolicyService { + + private final CardSetRepository cardSetRepository; + private final CardSetManagerRepository cardSetManagerRepository; + private final GroupService groupService; + + /** + * 그룹 ID와 카드셋 ID로 카드셋을 조회 + * + * @param groupId 조회할 카드셋이 속한 그룹의 ID + * @param cardSetId 조회할 카드셋의 ID + * @return 조회된 카드셋 엔티티 + * @author 윤정환 + */ + public CardSet findByIdAndGroupIdOrThrow(Long groupId, Long cardSetId) { + return cardSetRepository.findByIdAndGroup_Id(cardSetId, groupId) + .orElseThrow(() -> new BizException(CardSetErrorCode.CARD_SET_NOT_FOUND)); + } + + /** + * 특정 회원이 해당 카드셋을 수정할 수 있는 권한이 있는지 검증 + * + * @param userId 수정 권한을 검증할 회원의 ID + * @param cardSetId 수정 권한이 요구되는 카드셋의 ID + * @author 윤정환 + */ + public void validateCardSetEditable(Long userId, Long cardSetId) { + if (!cardSetManagerRepository.existsByUser_IdAndCardSet_Id(userId, cardSetId)) { + throw new BizException(CardSetErrorCode.CARD_SET_NO_EDIT_PERMISSION); + } + } + + /** + * 특정 회원이 해당 카드셋을 조회할 수 있는 권한이 있는지 검증 + * + * @param cardSet 조회 대상 카드셋 엔티티 + * @param userId 조회 권한을 검증할 회원의 ID + * @param groupId 카드셋이 속한 그룹의 ID + * @author 윤정환 + */ + public void validateCardSetViewable(CardSet cardSet, Long userId, Long groupId) { + if (!cardSet.getPublicVisible() && !groupService.existsMember(groupId, userId)) { + throw new BizException(CardSetErrorCode.CARD_SET_PRIVATE); + } + } +} diff --git a/src/main/java/project/flipnote/cardset/service/CardSetService.java b/src/main/java/project/flipnote/cardset/service/CardSetService.java index 948fc867..31732ed3 100644 --- a/src/main/java/project/flipnote/cardset/service/CardSetService.java +++ b/src/main/java/project/flipnote/cardset/service/CardSetService.java @@ -1,25 +1,33 @@ package project.flipnote.cardset.service; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import project.flipnote.cardset.entity.CardSet; import project.flipnote.cardset.entity.CardSetManager; import project.flipnote.cardset.exception.CardSetErrorCode; +import project.flipnote.cardset.model.CardSetDetailResponse; +import project.flipnote.cardset.model.CardSetSearchRequest; +import project.flipnote.cardset.model.CardSetSummaryResponse; +import project.flipnote.cardset.model.CardSetUpdatePayload; +import project.flipnote.cardset.model.CardSetUpdateRequest; import project.flipnote.cardset.model.CreateCardSetRequest; import project.flipnote.cardset.model.CreateCardSetResponse; import project.flipnote.cardset.repository.CardSetManagerRepository; import project.flipnote.cardset.repository.CardSetRepository; import project.flipnote.common.exception.BizException; +import project.flipnote.common.model.response.PagingResponse; import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.group.entity.Category; import project.flipnote.group.entity.Group; +import project.flipnote.group.entity.GroupPermissionStatus; import project.flipnote.group.exception.GroupErrorCode; import project.flipnote.group.repository.GroupMemberRepository; import project.flipnote.group.repository.GroupRepository; -import project.flipnote.groupjoin.exception.GroupJoinErrorCode; +import project.flipnote.group.service.GroupService; import project.flipnote.user.entity.UserProfile; import project.flipnote.user.entity.UserStatus; import project.flipnote.user.exception.UserErrorCode; @@ -36,6 +44,8 @@ public class CardSetService { private final GroupRepository groupRepository; private final GroupMemberRepository groupMemberRepository; private final CardSetManagerRepository cardSetManagerRepository; + private final GroupService groupService; + private final CardSetPolicyService cardSetPolicyService; private UserProfile validateUser(Long userId) { return userProfileRepository.findByIdAndStatus(userId, UserStatus.ACTIVE).orElseThrow( @@ -61,7 +71,7 @@ public CreateCardSetResponse createCardSet(Long groupId, AuthPrinciple authPrinc //그룹 정보 찾기 Group group = findGroup(groupId); - + //그룹 내 유저 있는지 확인 if (!existGroupMember(group, user)) { throw new BizException(CardSetErrorCode.GROUP_MEMBER_NOT_FOUND); @@ -91,7 +101,66 @@ public CreateCardSetResponse createCardSet(Long groupId, AuthPrinciple authPrinc cardSetManagerRepository.save(cardSetManager); - return CreateCardSetResponse.from(cardSet.getId()); } + + /** + * 카드셋 목록을 페이지 단위로 조회 + * + * @param req 조회 조건 및 페이징 정보를 포함한 요청 DTO + * @return 페이지 단위로 조회된 카드셋 목록 + * @author 윤정환 + */ + public PagingResponse getCardSets(CardSetSearchRequest req) { + + // TODO: Projection 및 카운트 쿼리 튜닝 필요, 좋아요 수 및 즐겨찾기 수 등 다양한 정렬 조건 추가 필요 + Page CardSetPage = cardSetRepository.findByNameContainingAndCategory( + req.getKeyword(), Category.from(req.getCategory()), req.getPageRequest() + ); + + Page res = CardSetPage.map(CardSetSummaryResponse::from); + + return PagingResponse.from(res); + } + + /** + * 카드셋 상세 조회 + * + * @param userId 카드셋 상세 조회하는 회원 ID + * @param groupId 카드셋을 생성한 그룹 ID + * @param cardSetId 상세 조회하려는 카드셋 ID + * @return 카드셋 상세 조회 정보 + * @author 윤정환 + */ + public CardSetDetailResponse getCardSet(Long userId, Long groupId, Long cardSetId) { + CardSet cardSet = cardSetPolicyService.findByIdAndGroupIdOrThrow(groupId, cardSetId); + + cardSetPolicyService.validateCardSetViewable(cardSet, userId, groupId); + + return CardSetDetailResponse.from(cardSet); + } + + /** + * 카드셋 수정 + * + * @param userId 카드셋 수정하는 회원 ID + * @param groupId 카드셋을 생성한 그룹 ID + * @param cardSetId 수정하려는 카드셋 ID + * @param req 카드셋의 수정 내용을 담은 요청 정보 + * @return 수정된 카드셋 정보 + * @author 윤정환 + */ + @Transactional + public CardSetDetailResponse updateCardSet(Long userId, Long groupId, Long cardSetId, CardSetUpdateRequest req) { + CardSet cardSet = cardSetPolicyService.findByIdAndGroupIdOrThrow(groupId, cardSetId); + + cardSetPolicyService.validateCardSetEditable(userId, cardSetId); + + CardSetUpdatePayload updatePayload = CardSetUpdatePayload.from(req); + cardSet.update(updatePayload); + + cardSetRepository.saveAndFlush(cardSet); + + return CardSetDetailResponse.from(cardSet); + } } diff --git a/src/main/java/project/flipnote/common/model/event/UserRegisteredEvent.java b/src/main/java/project/flipnote/common/model/event/UserRegisteredEvent.java index 3ca1e498..c19b4619 100644 --- a/src/main/java/project/flipnote/common/model/event/UserRegisteredEvent.java +++ b/src/main/java/project/flipnote/common/model/event/UserRegisteredEvent.java @@ -1,6 +1,7 @@ package project.flipnote.common.model.event; public record UserRegisteredEvent( - String email + String email, + Long userId ) { } diff --git a/src/main/java/project/flipnote/common/model/request/CursorPageRequest.java b/src/main/java/project/flipnote/common/model/request/CursorPagingRequest.java similarity index 53% rename from src/main/java/project/flipnote/common/model/request/CursorPageRequest.java rename to src/main/java/project/flipnote/common/model/request/CursorPagingRequest.java index 182786bb..7cbe84a6 100644 --- a/src/main/java/project/flipnote/common/model/request/CursorPageRequest.java +++ b/src/main/java/project/flipnote/common/model/request/CursorPagingRequest.java @@ -1,5 +1,7 @@ package project.flipnote.common.model.request; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.util.StringUtils; import io.swagger.v3.oas.annotations.media.Schema; @@ -10,7 +12,7 @@ @Getter @Setter -public class CursorPageRequest { +public class CursorPagingRequest { private String cursor; @@ -18,6 +20,10 @@ public class CursorPageRequest { @Max(30) private Integer size = 10; + private String sortBy; + + private String order = "desc"; + @Schema(hidden = true) public Long getCursorId() { if (!StringUtils.hasText(cursor)) { @@ -35,4 +41,20 @@ public Long getCursorId() { return null; } } + + @Schema(hidden = true) + public PageRequest getPageRequest() { + if (sortBy == null || sortBy.isEmpty()) { + return PageRequest.of(0, size); + } else { + Sort.Direction direction; + try { + direction = Sort.Direction.fromString(order); + } catch (IllegalArgumentException e) { + direction = Sort.Direction.DESC; + } + + return PageRequest.of(0, size, Sort.by(direction, sortBy)); + } + } } diff --git a/src/main/java/project/flipnote/common/model/request/PagingRequest.java b/src/main/java/project/flipnote/common/model/request/PagingRequest.java new file mode 100644 index 00000000..1fd7d51b --- /dev/null +++ b/src/main/java/project/flipnote/common/model/request/PagingRequest.java @@ -0,0 +1,42 @@ +package project.flipnote.common.model.request; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PagingRequest { + + @Min(1) + private Integer page = 1; + + @Min(1) + @Max(30) + private Integer size = 10; + + private String sortBy; + + private String order = "desc"; + + @Schema(hidden = true) + public PageRequest getPageRequest() { + if (sortBy == null || sortBy.isEmpty()) { + return PageRequest.of(page - 1, size + 1); + } else { + Sort.Direction direction; + try { + direction = Sort.Direction.fromString(order); + } catch (IllegalArgumentException e) { + direction = Sort.Direction.DESC; + } + + return PageRequest.of(page - 1, size + 1, Sort.by(direction, sortBy)); + } + } +} diff --git a/src/main/java/project/flipnote/common/model/response/CursorPageResponse.java b/src/main/java/project/flipnote/common/model/response/CursorPageResponse.java deleted file mode 100644 index 3f50df64..00000000 --- a/src/main/java/project/flipnote/common/model/response/CursorPageResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package project.flipnote.common.model.response; - -import java.util.List; -import java.util.Objects; - -public record CursorPageResponse( - List content, - boolean hasNext, - String nextCursor, - int size -) { - - public static CursorPageResponse of(List content, boolean hasNext, String nextCursor) { - return new CursorPageResponse<>(content, hasNext, hasNext ? nextCursor : null, content.size()); - } - - public static CursorPageResponse of(List content, boolean hasNext, Long nextCursorId) { - String nextCursor = Objects.toString(nextCursorId, null); - return of(content, hasNext, nextCursor); - } -} diff --git a/src/main/java/project/flipnote/common/model/response/CursorPagingResponse.java b/src/main/java/project/flipnote/common/model/response/CursorPagingResponse.java new file mode 100644 index 00000000..af9c6fac --- /dev/null +++ b/src/main/java/project/flipnote/common/model/response/CursorPagingResponse.java @@ -0,0 +1,21 @@ +package project.flipnote.common.model.response; + +import java.util.List; +import java.util.Objects; + +public record CursorPagingResponse( + List content, + boolean hasNext, + String nextCursor, + int size +) { + + public static CursorPagingResponse of(List content, boolean hasNext, String nextCursor) { + return new CursorPagingResponse<>(content, hasNext, hasNext ? nextCursor : null, content.size()); + } + + public static CursorPagingResponse of(List content, boolean hasNext, Long nextCursorId) { + String nextCursor = Objects.toString(nextCursorId, null); + return of(content, hasNext, nextCursor); + } +} diff --git a/src/main/java/project/flipnote/common/model/response/PageResponse.java b/src/main/java/project/flipnote/common/model/response/PagingResponse.java similarity index 79% rename from src/main/java/project/flipnote/common/model/response/PageResponse.java rename to src/main/java/project/flipnote/common/model/response/PagingResponse.java index 5f33e044..207a5bf0 100644 --- a/src/main/java/project/flipnote/common/model/response/PageResponse.java +++ b/src/main/java/project/flipnote/common/model/response/PagingResponse.java @@ -4,7 +4,7 @@ import org.springframework.data.domain.Page; -public record PageResponse( +public record PagingResponse( List content, int page, int size, @@ -16,8 +16,8 @@ public record PageResponse( boolean hasPrevious ) { - public static PageResponse from(Page page) { - return new PageResponse<>( + public static PagingResponse from(Page page) { + return new PagingResponse<>( page.getContent(), page.getNumber(), page.getSize(), diff --git a/src/main/java/project/flipnote/group/controller/GroupController.java b/src/main/java/project/flipnote/group/controller/GroupController.java index f9204460..980970cf 100644 --- a/src/main/java/project/flipnote/group/controller/GroupController.java +++ b/src/main/java/project/flipnote/group/controller/GroupController.java @@ -6,7 +6,6 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -17,7 +16,6 @@ import lombok.RequiredArgsConstructor; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.group.model.FindGroupMemberResponse; -import project.flipnote.group.model.FindGroupMemberResponse; import project.flipnote.group.model.GroupCreateRequest; import project.flipnote.group.model.GroupCreateResponse; import project.flipnote.group.model.GroupDetailResponse; diff --git a/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java b/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java index 5f9da72f..cc5b29c6 100644 --- a/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java +++ b/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java @@ -3,15 +3,17 @@ 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.ModelAttribute; 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.Valid; import lombok.RequiredArgsConstructor; -import project.flipnote.common.model.response.PageResponse; +import project.flipnote.common.model.response.PagingResponse; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.group.controller.docs.GroupInvitationQueryControllerDocs; +import project.flipnote.group.model.GroupInvitationListRequest; import project.flipnote.group.model.IncomingGroupInvitationResponse; import project.flipnote.group.model.OutgoingGroupInvitationResponse; import project.flipnote.group.service.GroupInvitationService; @@ -24,26 +26,24 @@ public class GroupInvitationQueryController implements GroupInvitationQueryContr private final GroupInvitationService groupInvitationService; @GetMapping("/groups/{groupId}/invitations") - public ResponseEntity> getOutgoingInvitations( + public ResponseEntity> getOutgoingInvitations( @PathVariable("groupId") Long groupId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, + @Valid @ModelAttribute GroupInvitationListRequest req, @AuthenticationPrincipal AuthPrinciple authPrinciple ) { - PageResponse res - = groupInvitationService.getOutgoingInvitations(authPrinciple.userId(), groupId, page, size); + PagingResponse res + = groupInvitationService.getOutgoingInvitations(authPrinciple.userId(), groupId, req); return ResponseEntity.ok(res); } @GetMapping("/group-invitations") - public ResponseEntity> getIncomingInvitations( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, + public ResponseEntity> getIncomingInvitations( + @Valid @ModelAttribute GroupInvitationListRequest req, @AuthenticationPrincipal AuthPrinciple authPrinciple ) { - PageResponse res - = groupInvitationService.getIncomingInvitations(authPrinciple.userId(), page, size); + PagingResponse res + = groupInvitationService.getIncomingInvitations(authPrinciple.userId(), req); return ResponseEntity.ok(res); } diff --git a/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java b/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java index b726b4f5..c102c7d2 100644 --- a/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java +++ b/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java @@ -5,10 +5,9 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import project.flipnote.common.model.response.PageResponse; +import project.flipnote.common.model.response.PagingResponse; import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.group.model.GroupInvitationListRequest; import project.flipnote.group.model.IncomingGroupInvitationResponse; import project.flipnote.group.model.OutgoingGroupInvitationResponse; @@ -16,17 +15,15 @@ public interface GroupInvitationQueryControllerDocs { @Operation(summary = "그룹 초대 보낸 목록 조회", security = {@SecurityRequirement(name = "access-token")}) - ResponseEntity> getOutgoingInvitations( + ResponseEntity> getOutgoingInvitations( Long groupId, - @Min(0) int page, - @Min(1) @Max(30) int size, + GroupInvitationListRequest req, AuthPrinciple authPrinciple ); @Operation(summary = "그룹 초대 받은 목록 조회", security = {@SecurityRequirement(name = "access-token")}) - ResponseEntity> getIncomingInvitations( - @Min(0) int page, - @Min(1) @Max(30) int size, + ResponseEntity> getIncomingInvitations( + GroupInvitationListRequest req, AuthPrinciple authPrinciple ); } diff --git a/src/main/java/project/flipnote/group/entity/Category.java b/src/main/java/project/flipnote/group/entity/Category.java index 26e8563e..eec50bf9 100644 --- a/src/main/java/project/flipnote/group/entity/Category.java +++ b/src/main/java/project/flipnote/group/entity/Category.java @@ -1,5 +1,16 @@ package project.flipnote.group.entity; public enum Category { - IT, ENGLISH, MATH, SCIENCE, HISTORY, GEOGRAPHY, KOREAN + IT, ENGLISH, MATH, SCIENCE, HISTORY, GEOGRAPHY, KOREAN; + + public static Category from(String category) { + if (category == null || category.isEmpty()) { + return null; + } + try { + return Category.valueOf(category); + } catch (IllegalArgumentException e) { + return null; + } + } } diff --git a/src/main/java/project/flipnote/group/listener/GuestGroupInvitationEventListener.java b/src/main/java/project/flipnote/group/listener/GuestGroupInvitationEventListener.java new file mode 100644 index 00000000..3cbb0965 --- /dev/null +++ b/src/main/java/project/flipnote/group/listener/GuestGroupInvitationEventListener.java @@ -0,0 +1,39 @@ +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.config.ClientProperties; +import project.flipnote.group.model.event.GuestGroupInvitationCreateEvent; +import project.flipnote.infra.email.EmailService; + +@Slf4j +@RequiredArgsConstructor +@Component +public class GuestGroupInvitationEventListener { + + private final EmailService emailService; + private final ClientProperties clientProperties; + + @Async + @Retryable( + maxAttempts = 3, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleGuestGroupInvitationCreateEvent(GuestGroupInvitationCreateEvent event) { + emailService.sendGuestGroupInvitation(event.email(), event.groupName(), clientProperties.getUrl()); + } + + @Recover + public void recover(Exception ex, GuestGroupInvitationCreateEvent event) { + log.error("비회원 그룹 초대 전송 처리 예외 발생: email={}, groupName={}", event.email(), event.groupName(), ex); + } +} diff --git a/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java b/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java index a12c4899..56e4729e 100644 --- a/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java +++ b/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java @@ -27,11 +27,11 @@ public class UserRegisteredEventListener { ) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleUserRegisteredEvent(UserRegisteredEvent event) { - groupInvitationService.acceptPendingInvitationsOnRegister(event.email()); + groupInvitationService.convertGuestInvitationToMember(event.email(), event.userId()); } @Recover public void recover(Exception ex, UserRegisteredEvent event) { - log.error("회원가입 후속 처리 예외 발생: email={}", event.email(), ex); + log.error("회원가입 후 비회원 그룹 초대 회원으로 변환 중 예외 발생: email={}, userId={}", event.email(), event.userId(), ex); } } diff --git a/src/main/java/project/flipnote/group/model/GroupInvitationListRequest.java b/src/main/java/project/flipnote/group/model/GroupInvitationListRequest.java new file mode 100644 index 00000000..68a1c9b4 --- /dev/null +++ b/src/main/java/project/flipnote/group/model/GroupInvitationListRequest.java @@ -0,0 +1,14 @@ +package project.flipnote.group.model; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +import project.flipnote.common.model.request.PagingRequest; + +public class GroupInvitationListRequest extends PagingRequest { + + @Override + public PageRequest getPageRequest() { + return PageRequest.of(getPage() - 1, getSize() + 1, Sort.by(Sort.Direction.DESC, "id")); + } +} diff --git a/src/main/java/project/flipnote/group/model/event/GuestGroupInvitationCreateEvent.java b/src/main/java/project/flipnote/group/model/event/GuestGroupInvitationCreateEvent.java new file mode 100644 index 00000000..b9b594ff --- /dev/null +++ b/src/main/java/project/flipnote/group/model/event/GuestGroupInvitationCreateEvent.java @@ -0,0 +1,7 @@ +package project.flipnote.group.model.event; + +public record GuestGroupInvitationCreateEvent( + String email, + String groupName +) { +} diff --git a/src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java b/src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java index e4bff22c..80fecb6c 100644 --- a/src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java +++ b/src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java @@ -1,7 +1,6 @@ package project.flipnote.group.repository; import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -24,8 +23,6 @@ Optional findByIdAndGroup_IdAndInviteeUserIdAndStatus( Page findAllByGroup_Id(Long groupId, Pageable pageable); - List findAllByInviteeEmailAndStatus(String inviteeEmail, GroupInvitationStatus status); - Page findAllByInviteeUserId(Long inviteeUserId, Pageable pageable); boolean existsByGroup_IdAndInviteeUserIdAndStatus(Long groupId, Long inviteeUserId, GroupInvitationStatus status); @@ -40,4 +37,12 @@ Optional findByIdAndGroup_IdAndInviteeUserIdAndStatus( AND gi.expiredAt < :now """) int bulkExpire(@Param("now") LocalDateTime now); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE GroupInvitation gi + SET gi.inviteeUserId = :inviteeUserId + WHERE gi.inviteeEmail = :inviteeEmail + """) + int bulkUpdateInviteeUserId(@Param("inviteeEmail") String inviteeEmail, @Param("inviteeUserId") Long inviteeUserId); } diff --git a/src/main/java/project/flipnote/group/service/GroupInvitationPolicyService.java b/src/main/java/project/flipnote/group/service/GroupInvitationPolicyService.java new file mode 100644 index 00000000..ed79e75b --- /dev/null +++ b/src/main/java/project/flipnote/group/service/GroupInvitationPolicyService.java @@ -0,0 +1,43 @@ +package project.flipnote.group.service; + +import java.util.Objects; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import project.flipnote.common.exception.BizException; +import project.flipnote.group.entity.GroupPermissionStatus; +import project.flipnote.group.exception.GroupInvitationErrorCode; + +@RequiredArgsConstructor +@Service +public class GroupInvitationPolicyService { + + private final GroupService groupService; + + /** + * 그룹 초대 권한을 검증 + * + * @param userId 권한을 검증할 회원 ID + * @param groupId 검증할 그룹 ID + * @author 윤정환 + */ + public void validateGroupInvitePermission(Long userId, Long groupId) { + if (!groupService.hasPermission(groupId, userId, GroupPermissionStatus.INVITE)) { + throw new BizException(GroupInvitationErrorCode.NO_INVITATION_PERMISSION); + } + } + + /** + * 자기 자신을 초대했는지 검증 + * + * @param inviterUserEmail 초대 보낸 회원 이메일 + * @param inviteeEmail 초대 받은 회원 이메일 + * @author 윤정환 + */ + public void validateSelfInvitation(String inviterUserEmail, String inviteeEmail) { + if (Objects.equals(inviterUserEmail, inviteeEmail)) { + throw new BizException(GroupInvitationErrorCode.CANNOT_INVITE_SELF); + } + } +} diff --git a/src/main/java/project/flipnote/group/service/GroupInvitationService.java b/src/main/java/project/flipnote/group/service/GroupInvitationService.java index d8e70385..83b47da6 100644 --- a/src/main/java/project/flipnote/group/service/GroupInvitationService.java +++ b/src/main/java/project/flipnote/group/service/GroupInvitationService.java @@ -6,14 +6,13 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import project.flipnote.common.exception.BizException; import project.flipnote.common.model.event.GroupInvitationCreatedEvent; -import project.flipnote.common.model.response.PageResponse; +import project.flipnote.common.model.response.PagingResponse; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.group.entity.GroupInvitation; import project.flipnote.group.entity.GroupInvitationStatus; @@ -22,9 +21,11 @@ import project.flipnote.group.exception.GroupInvitationErrorCode; import project.flipnote.group.model.GroupInvitationCreateRequest; import project.flipnote.group.model.GroupInvitationCreateResponse; +import project.flipnote.group.model.GroupInvitationListRequest; import project.flipnote.group.model.GroupInvitationRespondRequest; import project.flipnote.group.model.IncomingGroupInvitationResponse; import project.flipnote.group.model.OutgoingGroupInvitationResponse; +import project.flipnote.group.model.event.GuestGroupInvitationCreateEvent; import project.flipnote.group.repository.GroupInvitationRepository; import project.flipnote.group.repository.GroupMemberRepository; import project.flipnote.group.repository.GroupRepository; @@ -43,6 +44,7 @@ public class GroupInvitationService { private final GroupMemberRepository groupMemberRepository; private final GroupMemberPolicyService groupMemberPolicyService; private final ApplicationEventPublisher eventPublisher; + private final GroupInvitationPolicyService groupInvitationPolicyService; /** * 그룹에 회원 혹은 비회원 초대 @@ -59,13 +61,11 @@ public GroupInvitationCreateResponse createGroupInvitation( GroupInvitationCreateRequest req ) { Long inviterUserId = authPrinciple.userId(); - validateGroupInvitePermission(inviterUserId, groupId); + groupInvitationPolicyService.validateGroupInvitePermission(inviterUserId, groupId); String inviterUserEmail = authPrinciple.email(); String inviteeEmail = req.email(); - if (Objects.equals(inviterUserEmail, inviteeEmail)) { - throw new BizException(GroupInvitationErrorCode.CANNOT_INVITE_SELF); - } + groupInvitationPolicyService.validateSelfInvitation(inviterUserEmail, inviteeEmail); Long invitationId = userService.findActiveUserByEmail(inviteeEmail) .map(inviteeUser -> createUserInvitation(inviterUserId, groupId, inviteeUser)) @@ -84,7 +84,7 @@ public GroupInvitationCreateResponse createGroupInvitation( */ @Transactional public void deleteGroupInvitation(Long userId, Long groupId, Long invitationId) { - validateGroupInvitePermission(userId, groupId); + groupInvitationPolicyService.validateGroupInvitePermission(userId, groupId); GroupInvitation invitation = groupInvitationRepository .findByIdAndStatus(invitationId, GroupInvitationStatus.PENDING) @@ -135,20 +135,22 @@ public void respondToGroupInvitation( * * @param userId 초대 보낸 목록을 조회하는 회원 ID * @param groupId 초대한 그룹 ID - * @param page 페이지 번호 - * @param size 페이지 크기 + * @param req 페이징을 위한 정보 * @return 페이징된 그룹 초대 보낸 목록 응답 * @author 윤정환 */ - public PageResponse getOutgoingInvitations(Long userId, Long groupId, int page, - int size) { + public PagingResponse getOutgoingInvitations( + Long userId, + Long groupId, + GroupInvitationListRequest req + ) { 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); + Page invitationPage + = groupInvitationRepository.findAllByGroup_Id(groupId, req.getPageRequest()); List inviteeUserIds = invitationPage.getContent() .stream() @@ -163,25 +165,27 @@ public PageResponse getOutgoingInvitations(Long ) ); - return PageResponse.from(res); + return PagingResponse.from(res); } /** * 그룹 초대 받은 목록을 페이징하여 조회 * * @param userId 초대 받은 목록을 조회하는 회원 ID - * @param page 페이지 번호 - * @param size 페이지 크기 + * @param req 페이징을 위한 정보 * @return 페이징된 그룹 초대 받은 목록 응답 * @author 윤정환 */ - public PageResponse getIncomingInvitations(Long userId, int page, int size) { + public PagingResponse getIncomingInvitations( + Long userId, + GroupInvitationListRequest req + ) { // TODO: Projection 및 카운트 쿼리 튜닝 필요 - PageRequest pageRequest = PageRequest.of(page, size); - Page invitationPage = groupInvitationRepository.findAllByInviteeUserId(userId, pageRequest); + Page invitationPage + = groupInvitationRepository.findAllByInviteeUserId(userId, req.getPageRequest()); Page res = invitationPage.map(IncomingGroupInvitationResponse::from); - return PageResponse.from(res); + return PagingResponse.from(res); } /** @@ -191,37 +195,8 @@ public PageResponse getIncomingInvitations(Long * @author 윤정환 */ @Transactional - public void acceptPendingInvitationsOnRegister(String inviteeEmail) { - List invitations = groupInvitationRepository - .findAllByInviteeEmailAndStatus(inviteeEmail, GroupInvitationStatus.PENDING); - - for (GroupInvitation invitation : invitations) { - if (invitation.isExpired()) { - continue; - } - - try { - groupMemberPolicyService.addGroupMember(invitation.getInviteeUserId(), invitation.getGroup().getId()); - invitation.respond(GroupInvitationStatus.ACCEPTED); - } catch (BizException ex) { - if (ex.getErrorCode() == GroupErrorCode.ALREADY_GROUP_MEMBER) { - invitation.respond(GroupInvitationStatus.ACCEPTED); - } - } catch (Exception ignored) { } - } - } - - /** - * 그룹 초대 권한을 검증 - * - * @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); - } + public void convertGuestInvitationToMember(String inviteeEmail, Long inviteeUserId) { + groupInvitationRepository.bulkUpdateInviteeUserId(inviteeEmail, inviteeUserId); } /** @@ -278,7 +253,9 @@ private Long createGuestInvitation(Long inviterUserId, Long groupId, String invi .build(); groupInvitationRepository.save(invitation); - // TODO: 초대받은 비회원한테 이메일 전송 + String groupName = groupRepository.findGroupNameById(groupId) + .orElseThrow(() -> new BizException(GroupErrorCode.GROUP_NOT_FOUND)); + eventPublisher.publishEvent(new GuestGroupInvitationCreateEvent(inviteeEmail, groupName)); return invitation.getId(); } diff --git a/src/main/java/project/flipnote/group/service/GroupService.java b/src/main/java/project/flipnote/group/service/GroupService.java index d964890e..b0cbc229 100644 --- a/src/main/java/project/flipnote/group/service/GroupService.java +++ b/src/main/java/project/flipnote/group/service/GroupService.java @@ -6,7 +6,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import project.flipnote.common.exception.BizException; @@ -61,7 +60,7 @@ public UserProfile validateUser(AuthPrinciple authPrinciple) { 그룹 내 유저 검증 */ public void validateGroupInUser(UserProfile user, Long groupId) { - if(!groupMemberRepository.existsByGroup_IdAndUser_Id(groupId, user.getId())) { + if (!groupMemberRepository.existsByGroup_IdAndUser_Id(groupId, user.getId())) { throw new BizException(GroupJoinErrorCode.USER_NOT_IN_GROUP); } } @@ -305,4 +304,16 @@ public FindGroupMemberResponse findGroupMembers(AuthPrinciple authPrinciple, Lon return FindGroupMemberResponse.from(groupMembers); } + + /** + * 해당 회원에 그룹에 존재하는지 확인 + * + * @param groupId 검증할 그룹의 ID + * @param userId 검증할 회원의 ID + * @return 회원이 그룹 멤버인지 여부 + * @author 윤정환 + */ + public boolean existsMember(Long groupId, Long userId) { + return groupMemberRepository.existsByGroup_IdAndUser_Id(groupId, userId); + } } diff --git a/src/main/java/project/flipnote/infra/email/EmailService.java b/src/main/java/project/flipnote/infra/email/EmailService.java index c2cf3f6f..fa8ecbed 100644 --- a/src/main/java/project/flipnote/infra/email/EmailService.java +++ b/src/main/java/project/flipnote/infra/email/EmailService.java @@ -5,4 +5,6 @@ public interface EmailService { void sendEmailVerificationCode(String to, String code, int ttl); void sendPasswordResetLink(String to, String link, int ttl); + + void sendGuestGroupInvitation(String to, String groupName, String registerUrl); } diff --git a/src/main/java/project/flipnote/infra/email/ResendEmailService.java b/src/main/java/project/flipnote/infra/email/ResendEmailService.java index 78ba7b57..13dff543 100644 --- a/src/main/java/project/flipnote/infra/email/ResendEmailService.java +++ b/src/main/java/project/flipnote/infra/email/ResendEmailService.java @@ -67,4 +67,27 @@ public void sendPasswordResetLink(String to, String link, int ttl) { throw new EmailSendException(e); } } + + @Override + public void sendGuestGroupInvitation(String to, String groupName, String registerUrl) { + Context context = new Context(); + context.setVariable("groupName", groupName); + context.setVariable("registerUrl", registerUrl); + + String html = templateEngine.process("email/guest-group-invitation", context); + + CreateEmailOptions params = CreateEmailOptions.builder() + .from(resendProperties.getFromEmail()) + .to(to) + .subject("그룹 초대 안내") + .html(html) + .build(); + + try { + resend.emails().send(params); + } catch (ResendException e) { + log.error("비회원 그룹 초대 발송 실패: email={}", to, e); + throw new EmailSendException(e); + } + } } diff --git a/src/main/java/project/flipnote/notification/controller/NotificationController.java b/src/main/java/project/flipnote/notification/controller/NotificationController.java index 7aaa3e96..0349a76a 100644 --- a/src/main/java/project/flipnote/notification/controller/NotificationController.java +++ b/src/main/java/project/flipnote/notification/controller/NotificationController.java @@ -13,7 +13,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import project.flipnote.common.model.response.CursorPageResponse; +import project.flipnote.common.model.response.CursorPagingResponse; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.notification.controller.docs.NotificationControllerDocs; import project.flipnote.notification.model.NotificationListRequest; @@ -29,11 +29,11 @@ public class NotificationController implements NotificationControllerDocs { private final NotificationService notificationService; @GetMapping - public ResponseEntity> getNotifications( + public ResponseEntity> getNotifications( @Valid @ModelAttribute NotificationListRequest req, @AuthenticationPrincipal AuthPrinciple authPrinciple ) { - CursorPageResponse res + CursorPagingResponse res = notificationService.getNotifications(authPrinciple.userId(), req); return ResponseEntity.ok(res); diff --git a/src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java b/src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java index 232e1860..e25f84fc 100644 --- a/src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java +++ b/src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java @@ -4,7 +4,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import project.flipnote.common.model.response.CursorPageResponse; +import project.flipnote.common.model.response.CursorPagingResponse; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.notification.model.NotificationListRequest; import project.flipnote.notification.model.NotificationResponse; @@ -14,7 +14,7 @@ public interface NotificationControllerDocs { @Operation(summary = "알림 목록 조회") - ResponseEntity> getNotifications( + ResponseEntity> getNotifications( NotificationListRequest req, AuthPrinciple authPrinciple ); diff --git a/src/main/java/project/flipnote/notification/model/NotificationListRequest.java b/src/main/java/project/flipnote/notification/model/NotificationListRequest.java index 490d93cf..55b216b2 100644 --- a/src/main/java/project/flipnote/notification/model/NotificationListRequest.java +++ b/src/main/java/project/flipnote/notification/model/NotificationListRequest.java @@ -1,16 +1,24 @@ package project.flipnote.notification.model; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + import jakarta.validation.constraints.Min; import lombok.Getter; import lombok.Setter; -import project.flipnote.common.model.request.CursorPageRequest; +import project.flipnote.common.model.request.CursorPagingRequest; @Getter @Setter -public class NotificationListRequest extends CursorPageRequest { +public class NotificationListRequest extends CursorPagingRequest { @Min(1) private Long groupId; private Boolean read; + + @Override + public PageRequest getPageRequest() { + return PageRequest.of(0, getSize(), Sort.by(Sort.Direction.DESC, "id")); + } } diff --git a/src/main/java/project/flipnote/notification/service/NotificationService.java b/src/main/java/project/flipnote/notification/service/NotificationService.java index e7819d58..ecf41c71 100644 --- a/src/main/java/project/flipnote/notification/service/NotificationService.java +++ b/src/main/java/project/flipnote/notification/service/NotificationService.java @@ -11,9 +11,6 @@ import org.apache.commons.text.StringSubstitutor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.MessageSource; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,7 +21,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import project.flipnote.common.exception.BizException; -import project.flipnote.common.model.response.CursorPageResponse; +import project.flipnote.common.model.response.CursorPagingResponse; import project.flipnote.group.service.GroupService; import project.flipnote.infra.firebase.FcmErrorCode; import project.flipnote.infra.firebase.FirebaseService; @@ -62,10 +59,9 @@ public class NotificationService { * @return 커서 기반 페이징된 알림 목록 * @author 윤정환 */ - public CursorPageResponse getNotifications(Long userId, NotificationListRequest req) { - Pageable pageable = PageRequest.of(0, req.getSize() + 1, Sort.by("id").descending()); + public CursorPagingResponse getNotifications(Long userId, NotificationListRequest req) { List notifications = notificationRepository.findNotificationsByReceiverIdAndCursor( - userId, req.getCursorId(), req.getGroupId(), req.getRead(), pageable + userId, req.getCursorId(), req.getGroupId(), req.getRead(), req.getPageRequest() ); boolean hasNext = notifications.size() > req.getSize(); @@ -82,7 +78,7 @@ public CursorPageResponse getNotifications(Long userId, No })) .toList(); - return CursorPageResponse.of(content, hasNext, nextCursor); + return CursorPagingResponse.of(content, hasNext, nextCursor); } /** diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7a8051c8..1aeb9fda 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -60,18 +60,6 @@ app: encryption: key: ${APP_ENCRYPTION_KEY} -cloud: - aws: - region: ${S3_BUCKET_REGION} - credentials: - access-key: ${S3_ACCESS_KEY} - secret-key: ${S3_SECRET_KEY} - stack: - auto: false - s3: - bucket: ${S3_BUCKET_NAME} - - client: url: ${APP_CLIENT_URL:https://flipnote.site} paths: @@ -95,6 +83,17 @@ cloud: - email - profile +cloud: + aws: + region: ${S3_BUCKET_REGION} + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + stack: + auto: false + s3: + bucket: ${S3_BUCKET_NAME} + springdoc: server: url: http://localhost:8080 diff --git a/src/main/resources/templates/email/guest-group-invitation.html b/src/main/resources/templates/email/guest-group-invitation.html new file mode 100644 index 00000000..b09b54a2 --- /dev/null +++ b/src/main/resources/templates/email/guest-group-invitation.html @@ -0,0 +1,47 @@ + + + + + + + 그룹 초대 안내 + + +
+ 그룹 초대 메시지입니다. 아래 내용을 확인해 주세요. +
+ + + + + +
+ + + + +
+

+ 그룹 초대 안내 +

+

+ 안녕하세요.
+ [그룹명] 그룹에서 회원님을 초대하였습니다.
+ 아래 버튼을 눌러 회원가입 후 그룹에 참여해 주세요. +

+ +

+ 본 메일은 발신전용입니다. +

+
+
+ +