diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java index de913e9e..caee9867 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java @@ -214,6 +214,7 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom boolean isLikedByMe = command.userId() != null && myLikeMap.getOrDefault(c.getId(), false); String userProfileImageUrl = profileImageMap.get(c.getUser().getId()); String thumbnailProfileImageUrl = thumbnailProfileImageMap.get(c.getUser().getId()); + boolean isMine = command.userId() != null && command.userId().equals(c.getUser().getId()); return collectionWebMapper.toGetNearbyCollectionsResponseItem( c, @@ -223,7 +224,8 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom thumbnailProfileImageUrl, likeCount, commentCount, - isLikedByMe + isLikedByMe, + isMine ); }) .toList(); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java index 714d3c2b..3af7c454 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java @@ -55,7 +55,7 @@ public List findNearby(Point ref, double radiusMeters, Long if (isMineOnly && userId != null) { String sqlMineOnly = """ - SELECT * + SELECT c.* FROM user_bird_collection c WHERE ST_DWithin( c.location::geography, @@ -79,8 +79,9 @@ ORDER BY ST_Distance( } String sqlAll = """ - SELECT * + SELECT c.* FROM user_bird_collection c + LEFT JOIN bird b ON b.id = c.bird_id WHERE ST_DWithin( c.location::geography, CAST(:refPoint AS geography), @@ -90,6 +91,11 @@ WHERE ST_DWithin( c.access_level = 'PUBLIC' OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) ) + AND ( + c.bird_id IS NULL + OR b.conservation_grade = 'NONE' + OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) + ) ORDER BY ST_Distance( c.location::geography, CAST(:refPoint AS geography) @@ -130,6 +136,7 @@ WHERE ST_DWithin( String sql = """ SELECT COUNT(*) FROM user_bird_collection c + LEFT JOIN bird b ON b.id = c.bird_id WHERE ST_DWithin( c.location::geography, CAST(:refPoint AS geography), @@ -139,6 +146,11 @@ WHERE ST_DWithin( c.access_level = 'PUBLIC' OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) ) + AND ( + c.bird_id IS NULL + OR b.conservation_grade = 'NONE' + OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) + ) """; var query = em.createNativeQuery(sql) @@ -203,6 +215,7 @@ WITH candidates AS ( ST_Distance(c.location::geography, CAST(:refPoint AS geography)) AS dist, ST_SnapToGrid(ST_Transform(c.location, 3857), :gridSize, :gridSize) AS cell_id FROM user_bird_collection c + LEFT JOIN bird b ON b.id = c.bird_id WHERE ST_DWithin( c.location::geography, CAST(:refPoint AS geography), @@ -212,6 +225,11 @@ WHERE ST_DWithin( c.access_level = 'PUBLIC' OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) ) + AND ( + c.bird_id IS NULL + OR b.conservation_grade = 'NONE' + OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) + ) ), ranked AS ( SELECT id, dist, diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMasker.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMasker.java new file mode 100644 index 00000000..86ad1183 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMasker.java @@ -0,0 +1,53 @@ +package org.devkor.apu.saerok_server.domain.collection.core.service; + +import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; + +public final class CollectionLocationMasker { + + private CollectionLocationMasker() { + } + + public static Double latitude(UserBirdCollection collection, boolean isOwner) { + if (shouldMaskLocation(collection, isOwner) || collection == null || collection.getLocation() == null) { + return null; + } + return collection.getLatitude(); + } + + public static Double longitude(UserBirdCollection collection, boolean isOwner) { + if (shouldMaskLocation(collection, isOwner) || collection == null || collection.getLocation() == null) { + return null; + } + return collection.getLongitude(); + } + + public static String locationAlias(UserBirdCollection collection, boolean isOwner) { + if (shouldMaskLocation(collection, isOwner) || collection == null) { + return null; + } + return collection.getLocationAlias(); + } + + public static String address(UserBirdCollection collection, boolean isOwner) { + if (shouldMaskLocation(collection, isOwner) || collection == null) { + return null; + } + return collection.getAddress(); + } + + public static boolean shouldMaskLocation(UserBirdCollection collection, boolean isOwner) { + if (isOwner || collection == null) { + return false; + } + + Bird bird = collection.getBird(); + if (bird == null) { + return false; + } + + ConservationGrade grade = bird.getConservationGrade(); + return grade != null && grade.shouldHideLocation(); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java index 45b74c34..b511dcbf 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionWebMapper.java @@ -9,13 +9,15 @@ import org.devkor.apu.saerok_server.domain.collection.application.dto.DeleteCollectionCommand; import org.devkor.apu.saerok_server.domain.collection.application.dto.*; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; +import org.devkor.apu.saerok_server.domain.collection.core.service.CollectionLocationMasker; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; import org.mapstruct.Named; @Mapper( - componentModel = MappingConstants.ComponentModel.SPRING + componentModel = MappingConstants.ComponentModel.SPRING, + imports = CollectionLocationMasker.class ) public interface CollectionWebMapper { @@ -58,6 +60,10 @@ public interface CollectionWebMapper { @Mapping(target = "commentCount", source = "commentCount") @Mapping(target = "isLiked", source = "isLiked") @Mapping(target = "isMine", source = "isMine") + @Mapping(target = "latitude", expression = "java(CollectionLocationMasker.latitude(collection, isMine))") + @Mapping(target = "longitude", expression = "java(CollectionLocationMasker.longitude(collection, isMine))") + @Mapping(target = "locationAlias", expression = "java(CollectionLocationMasker.locationAlias(collection, isMine))") + @Mapping(target = "address", expression = "java(CollectionLocationMasker.address(collection, isMine))") GetCollectionDetailResponse toGetCollectionDetailResponse(UserBirdCollection collection, String imageUrl, String userProfileImageUrl, String thumbnailProfileImageUrl, long likeCount, long commentCount, boolean isLiked, boolean isMine); @Mapping(target = "collectionId", source = "collection.id") @@ -71,7 +77,11 @@ public interface CollectionWebMapper { @Mapping(target = "user.nickname", source = "collection.user.nickname") @Mapping(target = "user.profileImageUrl", source = "userProfileImageUrl") @Mapping(target = "user.thumbnailProfileImageUrl", source = "thumbnailProfileImageUrl") - GetNearbyCollectionsResponse.Item toGetNearbyCollectionsResponseItem(UserBirdCollection collection, String imageUrl, String thumbnailUrl, String userProfileImageUrl, String thumbnailProfileImageUrl, long likeCount, long commentCount, boolean isLiked); + @Mapping(target = "latitude", expression = "java(CollectionLocationMasker.latitude(collection, isMine))") + @Mapping(target = "longitude", expression = "java(CollectionLocationMasker.longitude(collection, isMine))") + @Mapping(target = "locationAlias", expression = "java(CollectionLocationMasker.locationAlias(collection, isMine))") + @Mapping(target = "address", expression = "java(CollectionLocationMasker.address(collection, isMine))") + GetNearbyCollectionsResponse.Item toGetNearbyCollectionsResponseItem(UserBirdCollection collection, String imageUrl, String thumbnailUrl, String userProfileImageUrl, String thumbnailProfileImageUrl, long likeCount, long commentCount, boolean isLiked, boolean isMine); @Named("getBirdId") default Long getBirdId(UserBirdCollection collection) { diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityDataAssembler.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityDataAssembler.java index ee60186d..8b6cbecf 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityDataAssembler.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityDataAssembler.java @@ -60,13 +60,14 @@ public List toCollectionInfos(List long commentCount = collectionCommentRepository.countByCollectionId(collection.getId()); boolean isLiked = userId != null && collectionLikeRepository.existsByUserIdAndCollectionId(userId, collection.getId()); boolean isPopular = popularStatusMap.getOrDefault(collection.getId(), false); + boolean isMine = userId != null && userId.equals(collection.getUser().getId()); Long suggestionUserCount = collection.getBird() == null ? suggestionUserCounts.getOrDefault(collection.getId(), 0L) : null; return communityWebMapper.toCommunityCollectionInfo( - collection, imageUrl, thumbnailImageUrl, userProfileImageUrl, thumbnailProfileImageUrl, likeCount, commentCount, isLiked, isPopular, suggestionUserCount + collection, imageUrl, thumbnailImageUrl, userProfileImageUrl, thumbnailProfileImageUrl, likeCount, commentCount, isLiked, isPopular, suggestionUserCount, isMine ); }) .toList(); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java index aa101213..c50669d1 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.community.mapper; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; +import org.devkor.apu.saerok_server.domain.collection.core.service.CollectionLocationMasker; import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo; import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityFreeBoardPostInfo; import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityUserInfo; @@ -11,7 +12,7 @@ import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, imports = OffsetDateTimeLocalizer.class) +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, imports = {OffsetDateTimeLocalizer.class, CollectionLocationMasker.class}) public interface CommunityWebMapper { @Mapping(target = "collectionId", source = "collection.id") @@ -19,10 +20,10 @@ public interface CommunityWebMapper { @Mapping(target = "thumbnailImageUrl", source = "thumbnailImageUrl") @Mapping(target = "discoveredDate", source = "collection.discoveredDate") @Mapping(target = "createdAt", expression = "java(OffsetDateTimeLocalizer.toSeoulLocalDateTime(collection.getCreatedAt()))") - @Mapping(target = "latitude", source = "collection.latitude") - @Mapping(target = "longitude", source = "collection.longitude") - @Mapping(target = "locationAlias", source = "collection.locationAlias") - @Mapping(target = "address", source = "collection.address") + @Mapping(target = "latitude", expression = "java(CollectionLocationMasker.latitude(collection, isMine))") + @Mapping(target = "longitude", expression = "java(CollectionLocationMasker.longitude(collection, isMine))") + @Mapping(target = "locationAlias", expression = "java(CollectionLocationMasker.locationAlias(collection, isMine))") + @Mapping(target = "address", expression = "java(CollectionLocationMasker.address(collection, isMine))") @Mapping(target = "note", source = "collection.note") @Mapping(target = "likeCount", source = "likeCount") @Mapping(target = "commentCount", source = "commentCount") @@ -41,7 +42,8 @@ CommunityCollectionInfo toCommunityCollectionInfo( Long commentCount, Boolean isLiked, Boolean isPopular, - Long suggestionUserCount + Long suggestionUserCount, + boolean isMine ); @Mapping(target = "userId", source = "user.id") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java index 7d60a39c..1a0503c5 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdDetailResponse.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response; import io.swagger.v3.oas.annotations.media.Schema; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import java.util.List; @@ -15,6 +16,9 @@ public class BirdDetailResponse { @Schema(description = "학명", example = "Pica pica") public String scientificName; + @Schema(description = "보호등급", example = "NONE", allowableValues = {"NONE", "GRADE_I", "GRADE_II"}) + public ConservationGrade conservationGrade; + @Schema(description = "분류학적 정보") public BirdTaxonomy taxonomy; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdFullSyncResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdFullSyncResponse.java index a050e235..bd10b64b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdFullSyncResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdFullSyncResponse.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.HabitatType; import java.time.OffsetDateTime; @@ -36,6 +37,9 @@ public static class BirdProfileItem { @Schema(description = "NIBR URL", example = "http://nibr...") private String nibrUrl; + @Schema(description = "보호등급", example = "NONE", allowableValues = {"NONE", "GRADE_I", "GRADE_II"}) + private ConservationGrade conservationGrade; + @Schema(description = "서식지 목록") private List habitats; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdSearchResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdSearchResponse.java index bdc4ba3e..f1df8fe8 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdSearchResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/api/dto/response/BirdSearchResponse.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import java.util.List; @@ -24,6 +25,9 @@ public static class BirdSearchItem { @Schema(description = "학명", example = "Pica pica") public String scientificName; + @Schema(description = "보호등급", example = "NONE", allowableValues = {"NONE", "GRADE_I", "GRADE_II"}) + public ConservationGrade conservationGrade; + @Schema(description = "썸네일 이미지 URL", example = "https://example.com/images/bird-thumb.jpg") public String thumbImageUrl; } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/Bird.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/Bird.java index d04bf8be..fc254471 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/Bird.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/entity/Bird.java @@ -3,6 +3,7 @@ import jakarta.persistence.*; import lombok.Getter; import org.devkor.apu.saerok_server.domain.dex.bird.core.contract.HasBodyLength; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.global.shared.entity.SoftDeletableAuditable; import java.util.List; @@ -31,6 +32,10 @@ public class Bird extends SoftDeletableAuditable implements HasBodyLength { @Column(name = "nibr_url") private String nibrUrl; + @Enumerated(EnumType.STRING) + @Column(name = "conservation_grade", nullable = false) + private ConservationGrade conservationGrade = ConservationGrade.NONE; + @OneToMany(mappedBy = "bird", cascade = CascadeType.ALL, orphanRemoval = true) private List images; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/enums/ConservationGrade.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/enums/ConservationGrade.java new file mode 100644 index 00000000..e0cd6813 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/core/enums/ConservationGrade.java @@ -0,0 +1,11 @@ +package org.devkor.apu.saerok_server.domain.dex.bird.core.enums; + +public enum ConservationGrade { + NONE, + GRADE_I, + GRADE_II; + + public boolean shouldHideLocation() { + return this != NONE; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java index 594bcd05..f748e7cc 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/mapper/BirdProfileViewMapper.java @@ -22,6 +22,9 @@ public abstract class BirdProfileViewMapper { // TODO: 프론트에서 nibrUrl null 처리가 되면, 이 nibrUrl 임시 처리(null 대신 빈 문자열)를 지운다. public abstract BirdFullSyncResponse.BirdProfileItem toDto(BirdProfileView birdProfileView); + @Mapping(target = "s3Url", ignore = true) + protected abstract BirdFullSyncResponse.BirdProfileItem.Image toDtoImage(BirdProfileView.Image image); + @AfterMapping protected void fillS3Urls( BirdProfileView source, diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/BirdProfileView.java b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/BirdProfileView.java index c615408f..0ff52f1d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/BirdProfileView.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/BirdProfileView.java @@ -8,6 +8,7 @@ import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdDescription; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdName; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdTaxonomy; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.HabitatType; import org.hibernate.annotations.Immutable; import org.hibernate.annotations.JdbcTypeCode; @@ -40,6 +41,10 @@ public class BirdProfileView implements HasBodyLength { @Column(name = "nibr_url") private String nibrUrl; + @Enumerated(EnumType.STRING) + @Column(name = "conservation_grade") + private ConservationGrade conservationGrade; + @JdbcTypeCode(SqlTypes.ARRAY) @Column(name = "habitats") private List habitats; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostCommentCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostCommentCommandService.java index 35f3d425..f0423225 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostCommentCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostCommentCommandService.java @@ -6,6 +6,7 @@ import org.devkor.apu.saerok_server.domain.freeboard.api.dto.request.UpdateFreeBoardPostCommentRequest; import org.devkor.apu.saerok_server.domain.freeboard.api.dto.response.CreateFreeBoardPostCommentResponse; import org.devkor.apu.saerok_server.domain.freeboard.api.dto.response.UpdateFreeBoardPostCommentResponse; +import org.devkor.apu.saerok_server.domain.freeboard.application.event.FreeBoardNotificationEvent; import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPost; import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPostComment; import org.devkor.apu.saerok_server.domain.freeboard.core.repository.FreeBoardPostCommentRepository; @@ -14,6 +15,7 @@ import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.devkor.apu.saerok_server.global.shared.exception.ForbiddenException; import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @Service @@ -24,6 +26,7 @@ public class FreeBoardPostCommentCommandService { private final FreeBoardPostCommentRepository commentRepository; private final FreeBoardPostRepository postRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; /* 댓글 작성 */ public CreateFreeBoardPostCommentResponse createComment(Long userId, Long postId, @@ -35,8 +38,9 @@ public CreateFreeBoardPostCommentResponse createComment(Long userId, Long postId .orElseThrow(() -> new NotFoundException("존재하지 않는 게시글 id예요")); FreeBoardPostComment comment; + FreeBoardPostComment parentComment = null; if (req.parentId() != null) { - FreeBoardPostComment parentComment = commentRepository.findById(req.parentId()) + parentComment = commentRepository.findById(req.parentId()) .orElseThrow(() -> new NotFoundException("존재하지 않는 댓글 id예요")); if (!parentComment.getPost().getId().equals(postId)) { @@ -55,6 +59,15 @@ public CreateFreeBoardPostCommentResponse createComment(Long userId, Long postId } commentRepository.save(comment); + + eventPublisher.publishEvent(new FreeBoardNotificationEvent.CommentCreated( + userId, user.getNickname(), + postId, post.getUser().getId(), + parentComment != null ? parentComment.getId() : null, + parentComment != null ? parentComment.getUser().getId() : null, + req.content() + )); + return new CreateFreeBoardPostCommentResponse(comment.getId()); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationEvent.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationEvent.java new file mode 100644 index 00000000..6929405e --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationEvent.java @@ -0,0 +1,11 @@ +package org.devkor.apu.saerok_server.domain.freeboard.application.event; + +public sealed interface FreeBoardNotificationEvent { + + record CommentCreated( + Long actorId, String actorNickname, + Long postId, Long postOwnerId, + Long parentCommentId, Long parentCommentOwnerId, + String commentContent + ) implements FreeBoardNotificationEvent {} +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationWorker.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationWorker.java new file mode 100644 index 00000000..3592de9c --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationWorker.java @@ -0,0 +1,63 @@ +package org.devkor.apu.saerok_server.domain.freeboard.application.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.ActionKind; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Actor; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FreeBoardNotificationWorker { + + private final NotifyActionDsl notifyAction; + + @Async("pushNotificationExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(FreeBoardNotificationEvent.CommentCreated event) { + try { + Actor actor = Actor.of(event.actorId(), event.actorNickname()); + + if (event.parentCommentId() != null) { + // 대댓글: 원댓글 작성자에게 REPLY 알림 + if (!event.parentCommentOwnerId().equals(event.actorId())) { + notifyAction + .by(actor) + .on(Target.freeBoardComment(event.parentCommentId())) + .did(ActionKind.REPLY) + .comment(event.commentContent()) + .to(event.parentCommentOwnerId()); + } + // 게시글 소유자에게 COMMENT 알림 (원댓글 작성자와 다른 경우에만) + if (!event.postOwnerId().equals(event.actorId()) + && !event.postOwnerId().equals(event.parentCommentOwnerId())) { + notifyAction + .by(actor) + .on(Target.freeBoardPost(event.postId())) + .did(ActionKind.COMMENT) + .comment(event.commentContent()) + .to(event.postOwnerId()); + } + } else { + // 원댓글: 게시글 소유자에게 COMMENT 알림 + if (!event.postOwnerId().equals(event.actorId())) { + notifyAction + .by(actor) + .on(Target.freeBoardPost(event.postId())) + .did(ActionKind.COMMENT) + .comment(event.commentContent()) + .to(event.postOwnerId()); + } + } + } catch (Exception e) { + log.error("Failed to send freeboard comment notification: postId={}, actorId={}", + event.postId(), event.actorId(), e); + } + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/DelegatingTargetMetadataAdapter.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/DelegatingTargetMetadataAdapter.java index 741ac715..a81ac2a7 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/DelegatingTargetMetadataAdapter.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/DelegatingTargetMetadataAdapter.java @@ -18,12 +18,16 @@ public class DelegatingTargetMetadataAdapter implements TargetMetadataPort { private final CollectionTargetMetadataAdapter collectionAdapter; private final CommentTargetMetadataAdapter commentAdapter; + private final FreeBoardPostTargetMetadataAdapter freeBoardPostAdapter; + private final FreeBoardCommentTargetMetadataAdapter freeBoardCommentAdapter; @Override public Map enrich(Target target, Map baseExtras) { return switch (target.type()) { case COLLECTION -> collectionAdapter.enrich(target, baseExtras); case COMMENT -> commentAdapter.enrich(target, baseExtras); + case FREE_BOARD_POST -> freeBoardPostAdapter.enrich(target, baseExtras); + case FREE_BOARD_COMMENT -> freeBoardCommentAdapter.enrich(target, baseExtras); }; } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/FreeBoardCommentTargetMetadataAdapter.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/FreeBoardCommentTargetMetadataAdapter.java new file mode 100644 index 00000000..b98d3651 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/FreeBoardCommentTargetMetadataAdapter.java @@ -0,0 +1,38 @@ +package org.devkor.apu.saerok_server.domain.notification.application.adapter; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.freeboard.core.repository.FreeBoardPostCommentRepository; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; +import org.devkor.apu.saerok_server.domain.notification.application.port.TargetMetadataPort; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * TargetType.FREE_BOARD_COMMENT 전용 메타데이터 어댑터.
+ * - extras.freeBoardCommentId
+ * - extras.freeBoardPostId (댓글이 속한 자유게시판 글) + */ +@Component +@RequiredArgsConstructor +public class FreeBoardCommentTargetMetadataAdapter implements TargetMetadataPort { + + private final FreeBoardPostCommentRepository commentRepository; + + @Override + public Map enrich(Target target, Map baseExtras) { + if (target.type() != TargetType.FREE_BOARD_COMMENT) { + return baseExtras != null ? baseExtras : Map.of(); + } + + Map extras = baseExtras != null ? new HashMap<>(baseExtras) : new HashMap<>(); + extras.put("freeBoardCommentId", target.id()); + + commentRepository.findById(target.id()) + .ifPresent(comment -> extras.put("freeBoardPostId", comment.getPost().getId())); + + return extras; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/FreeBoardPostTargetMetadataAdapter.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/FreeBoardPostTargetMetadataAdapter.java new file mode 100644 index 00000000..9d3b0efb --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/FreeBoardPostTargetMetadataAdapter.java @@ -0,0 +1,30 @@ +package org.devkor.apu.saerok_server.domain.notification.application.adapter; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; +import org.devkor.apu.saerok_server.domain.notification.application.port.TargetMetadataPort; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * TargetType.FREE_BOARD_POST 전용 메타데이터 어댑터.
+ * - extras.freeBoardPostId + */ +@Component +@RequiredArgsConstructor +public class FreeBoardPostTargetMetadataAdapter implements TargetMetadataPort { + + @Override + public Map enrich(Target target, Map baseExtras) { + if (target.type() != TargetType.FREE_BOARD_POST) { + return baseExtras != null ? baseExtras : Map.of(); + } + + Map extras = baseExtras != null ? new HashMap<>(baseExtras) : new HashMap<>(); + extras.put("freeBoardPostId", target.id()); + return extras; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java index 5f827c91..fc016b56 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java @@ -103,6 +103,8 @@ public void to(Long recipientId) { var notificationSubject = switch (target.type()) { case COLLECTION -> NotificationSubject.COLLECTION; case COMMENT -> NotificationSubject.COMMENT; + case FREE_BOARD_POST -> NotificationSubject.FREE_BOARD_POST; + case FREE_BOARD_COMMENT -> NotificationSubject.FREE_BOARD_COMMENT; }; var notificationAction = switch (action) { @@ -112,9 +114,15 @@ public void to(Long recipientId) { case SUGGEST_BIRD_ID -> NotificationAction.SUGGEST_BIRD_ID; }; - Long relatedId = target.type() == TargetType.COMMENT && extras.containsKey("collectionId") - ? (Long) extras.get("collectionId") - : target.id(); + Long relatedId = switch (target.type()) { + case COMMENT -> extras.containsKey("collectionId") + ? (Long) extras.get("collectionId") + : target.id(); + case FREE_BOARD_COMMENT -> extras.containsKey("freeBoardPostId") + ? (Long) extras.get("freeBoardPostId") + : target.id(); + default -> target.id(); + }; publisher.push( new ActionNotificationPayload( diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/Target.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/Target.java index 29de4cb7..04927c28 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/Target.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/Target.java @@ -3,4 +3,6 @@ public record Target(TargetType type, Long id) { public static Target collection(Long id) { return new Target(TargetType.COLLECTION, id); } public static Target comment(Long id) { return new Target(TargetType.COMMENT, id); } + public static Target freeBoardPost(Long id) { return new Target(TargetType.FREE_BOARD_POST, id); } + public static Target freeBoardComment(Long id) { return new Target(TargetType.FREE_BOARD_COMMENT, id); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/TargetType.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/TargetType.java index d52012cc..820f4292 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/TargetType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/TargetType.java @@ -3,4 +3,6 @@ public enum TargetType { COLLECTION, COMMENT, + FREE_BOARD_POST, + FREE_BOARD_COMMENT, } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationSubject.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationSubject.java index e3486dd1..4dafc7e3 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationSubject.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationSubject.java @@ -1,6 +1,8 @@ package org.devkor.apu.saerok_server.domain.notification.core.entity; public enum NotificationSubject { - COLLECTION, // "내 컬렉션에 대한 활동" - COMMENT, // "내 댓글에 대한 활동" + COLLECTION, // "내 컬렉션에 대한 활동" + COMMENT, // "내 컬렉션 댓글에 대한 활동" + FREE_BOARD_POST, // "내 자유게시판 글에 대한 활동" + FREE_BOARD_COMMENT, // "내 자유게시판 댓글에 대한 활동" } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java index 84f01bd8..ec4432cd 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java @@ -15,6 +15,8 @@ public enum NotificationType { COMMENTED_ON_COLLECTION, REPLIED_TO_COMMENT, SUGGESTED_BIRD_ID_ON_COLLECTION, + COMMENTED_ON_FREE_BOARD_POST, + REPLIED_TO_FREE_BOARD_COMMENT, // ---- System Notification Types ---- SYSTEM_PUBLISHED_ANNOUNCEMENT, diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java index 2865f948..d432a10e 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java @@ -70,6 +70,18 @@ public void save(NotificationSetting setting) { em.persist(setting); } + public void insertIfMissing(Long userDeviceId, NotificationType type, boolean enabled) { + em.createNativeQuery(""" + insert into notification_setting (id, user_device_id, type, enabled, created_at, updated_at) + values (nextval('notification_setting_seq'), :userDeviceId, :type, :enabled, now(), now()) + on conflict on constraint uq_notification_setting_user_device_type do nothing + """) + .setParameter("userDeviceId", userDeviceId) + .setParameter("type", type.name()) + .setParameter("enabled", enabled) + .executeUpdate(); + } + public int deleteByUserId(Long userId) { return em.createQuery(""" delete from NotificationSetting ns diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationSettingBackfillService.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationSettingBackfillService.java index 5c55b29d..d07797cd 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationSettingBackfillService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationSettingBackfillService.java @@ -1,7 +1,6 @@ package org.devkor.apu.saerok_server.domain.notification.core.service; import lombok.RequiredArgsConstructor; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSetting; import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice; import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationSettingRepository; @@ -9,11 +8,6 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import java.util.EnumSet; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - @Service @RequiredArgsConstructor public class NotificationSettingBackfillService { @@ -30,19 +24,13 @@ public void ensureDefaultsNewTx(UserDevice device) { /** 동일 트랜잭션 내 보정 */ @Transactional public void ensureDefaults(UserDevice device) { - List existing = settingRepository.findByUserDeviceId(device.getId()); - - Set have = new HashSet<>(); - for (NotificationSetting ns : existing) have.add(ns.getType()); - - Set need = EnumSet.copyOf(schema.requiredTypes()); - need.removeAll(have); + if (device == null || device.getId() == null) { + throw new IllegalArgumentException("userDevice는 저장된 엔티티여야 합니다"); + } - if (!need.isEmpty()) { - for (NotificationType t : need) { - // 디폴트 on/off 정책: 기존 로직이 없으므로 기본 true로 시작(필요시 정책 변경) - settingRepository.save(NotificationSetting.of(device, t, true)); - } + for (NotificationType t : schema.requiredTypes()) { + // 디폴트 on/off 정책: 기존 로직이 없으므로 기본 true로 시작(필요시 정책 변경) + settingRepository.insertIfMissing(device.getId(), t, true); } } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationTypeResolver.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationTypeResolver.java index 56fd0ba3..38e70ab5 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationTypeResolver.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationTypeResolver.java @@ -24,6 +24,14 @@ public static NotificationType from(NotificationSubject subject, NotificationAct case REPLY -> NotificationType.REPLIED_TO_COMMENT; case LIKE, COMMENT, SUGGEST_BIRD_ID -> throw new IllegalArgumentException(action + " action is not supported for COMMENT subject"); }; + case FREE_BOARD_POST -> switch (action) { + case COMMENT -> NotificationType.COMMENTED_ON_FREE_BOARD_POST; + case LIKE, REPLY, SUGGEST_BIRD_ID -> throw new IllegalArgumentException(action + " action is not supported for FREE_BOARD_POST subject"); + }; + case FREE_BOARD_COMMENT -> switch (action) { + case REPLY -> NotificationType.REPLIED_TO_FREE_BOARD_COMMENT; + case LIKE, COMMENT, SUGGEST_BIRD_ID -> throw new IllegalArgumentException(action + " action is not supported for FREE_BOARD_COMMENT subject"); + }; }; } } diff --git a/src/main/resources/config/notification-messages.yml b/src/main/resources/config/notification-messages.yml index 516e772b..2f0657e7 100644 --- a/src/main/resources/config/notification-messages.yml +++ b/src/main/resources/config/notification-messages.yml @@ -20,6 +20,16 @@ notification-messages: push-body: "두근두근! 새로운 의견이 공유되었어요. 확인해볼까요?" batch-push-title: "동정 의견 공유" batch-push-body: "{count}개의 새로운 의견이 공유되었어요. 확인해볼까요?" + COMMENTED_ON_FREE_BOARD_POST: + push-title: "{actorName}" + push-body: "나의 게시글에 댓글을 남겼어요. \"{comment}\"" + batch-push-title: "{actorName} 외 {othersCount}명" + batch-push-body: "나의 게시글에 댓글을 남겼어요." + REPLIED_TO_FREE_BOARD_COMMENT: + push-title: "{actorName}" + push-body: "나의 댓글에 답글을 남겼어요. \"{comment}\"" + batch-push-title: "{actorName} 외 {othersCount}명" + batch-push-body: "나의 댓글에 답글을 남겼어요." SYSTEM_PUBLISHED_ANNOUNCEMENT: push-title: "{title}" push-body: "{body}" diff --git a/src/main/resources/db/migration/V91__add_bird_conservation_grade.sql b/src/main/resources/db/migration/V91__add_bird_conservation_grade.sql new file mode 100644 index 00000000..7a2e1e9b --- /dev/null +++ b/src/main/resources/db/migration/V91__add_bird_conservation_grade.sql @@ -0,0 +1,140 @@ +ALTER TABLE bird + ADD COLUMN conservation_grade VARCHAR(16) NOT NULL DEFAULT 'NONE'; + +ALTER TABLE bird + ADD CONSTRAINT chk_bird_conservation_grade + CHECK (conservation_grade IN ('NONE', 'GRADE_I', 'GRADE_II')); + +UPDATE bird +SET conservation_grade = 'GRADE_I' +WHERE id IN ( + 58, 59, 86, 90, 100, 128, 161, 214, 215, 219, + 235, 257, 258, 275, 473, 518, 600 +); + +UPDATE bird +SET conservation_grade = 'GRADE_II' +WHERE id IN ( + 27, 31, 33, 49, 50, 53, 57, 60, 73, 93, + 97, 98, 99, 102, 123, 135, 150, 162, 164, 179, + 201, 217, 218, 222, 224, 250, 253, 254, 255, 256, + 261, 264, 265, 266, 267, 268, 269, 271, 274, 287, + 395, 399, 403, 410, 411, 421, 425, 433, 444, 445, + 452, 459, 529, 530, 537, 538, 553, 577, 583, 592, + 593, 595, 602 +); + +DROP MATERIALIZED VIEW IF EXISTS bird_profile_mv; + +CREATE MATERIALIZED VIEW bird_profile_mv AS +WITH month_season AS ( + SELECT m AS month, + CASE WHEN m IN (3,4,5) THEN 'SPRING' + WHEN m IN (6,7,8) THEN 'SUMMER' + WHEN m IN (9,10,11) THEN 'AUTUMN' + ELSE 'WINTER' + END AS season + FROM generate_series(1,12) AS g(m) +), + bird_month_priority AS ( + SELECT br.bird_id, + ms.month, + MAX(rt.priority) AS priority + FROM bird_residency br + JOIN rarity_type rt ON rt.id = br.rarity_type_id + JOIN residency_type rty ON rty.id = br.residency_type_id + JOIN month_season ms ON ((COALESCE(br.month_bitmask, rty.month_bitmask) + >> (ms.month-1)) & 1) = 1 + GROUP BY br.bird_id, ms.month + ), + bird_season_priority AS ( + SELECT bmp.bird_id, + ms.season, + MAX(bmp.priority) AS priority + FROM bird_month_priority bmp + JOIN month_season ms ON ms.month = bmp.month + GROUP BY bmp.bird_id, ms.season + ), + bird_season_rarity AS ( + SELECT bsp.bird_id, + bsp.season, + bsp.priority, + rt.code AS rarity_code + FROM bird_season_priority bsp + JOIN rarity_type rt ON rt.priority = bsp.priority + ), + seasons_json AS ( + SELECT bird_id, + jsonb_agg( + jsonb_build_object( + 'season', season, + 'rarity', rarity_code, + 'priority', priority + ) + ORDER BY array_position( + ARRAY['SPRING','SUMMER','AUTUMN','WINTER'], season + ) + ) AS seasons_with_rarity + FROM bird_season_rarity + GROUP BY bird_id + ), + habitats_array AS ( + SELECT bird_id, + array_agg(DISTINCT habitat_type) AS habitats + FROM bird_habitat + GROUP BY bird_id + ), + images_json AS ( + SELECT bird_id, + jsonb_agg( + jsonb_build_object( + 'object_key', object_key, + 'original_url', original_url, + 'order_index', order_index, + 'is_thumb', is_thumb + ) + ORDER BY order_index + ) AS images + FROM bird_image + GROUP BY bird_id + ) +SELECT + b.id, + b.korean_name, + b.scientific_name, + b.scientific_year, + b.description_is_ai_generated, + b.class_eng, + b.class_kor, + b."order_eng", + b."order_kor", + b.family_eng, + b.family_kor, + b.genus_eng, + b.genus_kor, + b.species_eng, + b.species_kor, + b.scientific_author, + b.phylum_eng, + b.phylum_kor, + b.nibr_url, + b.conservation_grade, + b.description, + b.description_source, + ha.habitats, + b.body_length_cm, + COALESCE(sj.seasons_with_rarity, '[]'::jsonb) AS seasons_with_rarity, + COALESCE(ij.images, '[]'::jsonb) AS images, + b.created_at, + b.updated_at, + b.deleted_at +FROM bird b + LEFT JOIN habitats_array ha ON ha.bird_id = b.id + LEFT JOIN seasons_json sj ON sj.bird_id = b.id + LEFT JOIN images_json ij ON ij.bird_id = b.id +WHERE b.deleted_at IS NULL +WITH NO DATA; + +CREATE UNIQUE INDEX idx_bird_profile_mv_id ON bird_profile_mv(id); + +REFRESH MATERIALIZED VIEW bird_profile_mv; diff --git a/src/main/resources/db/migration/V92__migrate_legacy_notification_setting_types.sql b/src/main/resources/db/migration/V92__migrate_legacy_notification_setting_types.sql new file mode 100644 index 00000000..ea5c7c40 --- /dev/null +++ b/src/main/resources/db/migration/V92__migrate_legacy_notification_setting_types.sql @@ -0,0 +1,24 @@ +DELETE FROM notification_setting legacy +WHERE legacy.type = 'SYSTEM_CONTENT_DELETED' + AND EXISTS ( + SELECT 1 + FROM notification_setting current + WHERE current.user_device_id = legacy.user_device_id + AND current.type = 'SYSTEM_ADMIN_MESSAGE' + ); + +UPDATE notification_setting +SET type = 'SYSTEM_ADMIN_MESSAGE' +WHERE type = 'SYSTEM_CONTENT_DELETED'; + +DELETE FROM notification_setting +WHERE type NOT IN ( + 'LIKED_ON_COLLECTION', + 'COMMENTED_ON_COLLECTION', + 'REPLIED_TO_COMMENT', + 'SUGGESTED_BIRD_ID_ON_COLLECTION', + 'COMMENTED_ON_FREE_BOARD_POST', + 'REPLIED_TO_FREE_BOARD_COMMENT', + 'SYSTEM_PUBLISHED_ANNOUNCEMENT', + 'SYSTEM_ADMIN_MESSAGE' +); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java index e1cfcccf..c0d23ce5 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java @@ -2,8 +2,11 @@ import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; +import org.devkor.apu.saerok_server.testsupport.builder.BirdBuilder; import org.devkor.apu.saerok_server.testsupport.builder.CollectionBuilder; import org.devkor.apu.saerok_server.testsupport.builder.UserBuilder; import org.junit.jupiter.api.DisplayName; @@ -45,9 +48,19 @@ private UserBirdCollection newCollection( User owner, Point point, AccessLevelType accessLevel + ) { + return newCollection(owner, null, point, accessLevel); + } + + private UserBirdCollection newCollection( + User owner, + Bird bird, + Point point, + AccessLevelType accessLevel ) { return new CollectionBuilder(em) .owner(owner) + .bird(bird) .location(point) .accessLevel(accessLevel) .build(); @@ -163,6 +176,107 @@ void findNearby_withLimit_appliesMaxResults() throws Exception { assertEquals(2, result.size()); } + @Test + @DisplayName("주변 조회는 보호등급 새 컬렉션을 결과에서 제외한다") + void findNearby_excludesProtectedBirdCollections() throws Exception { + // given + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + + Bird normalBird = new BirdBuilder(em) + .korName("normal-nearby-" + System.nanoTime()) + .build(); + Bird protectedBird = new BirdBuilder(em) + .korName("protected-nearby-" + System.nanoTime()) + .conservationGrade(ConservationGrade.GRADE_I) + .build(); + + UserBirdCollection normalCollection = newCollection(owner, normalBird, near, AccessLevelType.PUBLIC); + UserBirdCollection protectedCollection = newCollection(owner, protectedBird, near, AccessLevelType.PUBLIC); + + em.flush(); + em.clear(); + + // when + List result = collectionRepository.findNearby(ref, 1_000, null, false, null); + long candidateCount = collectionRepository.countNearbyCandidates(ref, 1_000, null, false); + + // then + assertEquals(1, result.size()); + assertEquals(1L, candidateCount); + assertTrue(result.stream().anyMatch(c -> c.getId().equals(normalCollection.getId()))); + assertFalse(result.stream().anyMatch(c -> c.getId().equals(protectedCollection.getId()))); + } + + @Test + @DisplayName("mineOnly 주변 조회는 내 보호등급 새 컬렉션도 결과에 포함한다") + void findNearby_mineOnly_includesMyProtectedBirdCollections() throws Exception { + // given + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + + Bird normalBird = new BirdBuilder(em) + .korName("normal-mine-nearby-" + System.nanoTime()) + .build(); + Bird protectedBird = new BirdBuilder(em) + .korName("protected-mine-nearby-" + System.nanoTime()) + .conservationGrade(ConservationGrade.GRADE_II) + .build(); + + UserBirdCollection normalCollection = newCollection(owner, normalBird, near, AccessLevelType.PRIVATE); + UserBirdCollection protectedCollection = newCollection(owner, protectedBird, near, AccessLevelType.PRIVATE); + + em.flush(); + em.clear(); + + // when + List result = collectionRepository.findNearby(ref, 1_000, owner.getId(), true, null); + long candidateCount = collectionRepository.countNearbyCandidates(ref, 1_000, owner.getId(), true); + + // then + assertEquals(2, result.size()); + assertEquals(2L, candidateCount); + assertTrue(result.stream().anyMatch(c -> c.getId().equals(normalCollection.getId()))); + assertTrue(result.stream().anyMatch(c -> c.getId().equals(protectedCollection.getId()))); + } + + @Test + @DisplayName("로그인 주변 조회는 내 보호등급 새 컬렉션만 포함하고 타인의 보호등급 새 컬렉션은 제외한다") + void findNearby_all_includesMyProtectedBirdCollectionsOnly() throws Exception { + // given + User me = newUser(); + User other = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + + Bird myProtectedBird = new BirdBuilder(em) + .korName("my-protected-nearby-" + System.nanoTime()) + .conservationGrade(ConservationGrade.GRADE_I) + .build(); + Bird otherProtectedBird = new BirdBuilder(em) + .korName("other-protected-nearby-" + System.nanoTime()) + .conservationGrade(ConservationGrade.GRADE_II) + .build(); + + UserBirdCollection myProtectedCollection = newCollection(me, myProtectedBird, near, AccessLevelType.PRIVATE); + UserBirdCollection otherProtectedCollection = newCollection(other, otherProtectedBird, near, AccessLevelType.PUBLIC); + + em.flush(); + em.clear(); + + // when + List result = collectionRepository.findNearby(ref, 1_000, me.getId(), false, null); + long candidateCount = collectionRepository.countNearbyCandidates(ref, 1_000, me.getId(), false); + + // then + assertEquals(1, result.size()); + assertEquals(1L, candidateCount); + assertTrue(result.stream().anyMatch(c -> c.getId().equals(myProtectedCollection.getId()))); + assertFalse(result.stream().anyMatch(c -> c.getId().equals(otherProtectedCollection.getId()))); + } + @Test @DisplayName("findNearbyEven은 셀 단위 라운드로빈으로 균등 샘플링한다") void findNearbyEven_returnsBalancedSamplesAcrossCells() throws Exception { @@ -200,4 +314,75 @@ void findNearbyEven_returnsBalancedSamplesAcrossCells() throws Exception { assertFalse(result.stream().anyMatch(c -> c.getId().equals(cellASecond.getId())), "같은 셀의 두 번째 후보는 limit보다 뒤 순위로 밀려야 한다"); } + + @Test + @DisplayName("findNearbyEven도 보호등급 새 컬렉션을 결과에서 제외한다") + void findNearbyEven_excludesProtectedBirdCollections() throws Exception { + // given + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + + Bird normalBird = new BirdBuilder(em) + .korName("normal-even-nearby-" + System.nanoTime()) + .build(); + Bird protectedBird = new BirdBuilder(em) + .korName("protected-even-nearby-" + System.nanoTime()) + .conservationGrade(ConservationGrade.GRADE_I) + .build(); + + UserBirdCollection normalCollection = newCollection(owner, normalBird, near, AccessLevelType.PUBLIC); + UserBirdCollection protectedCollection = newCollection(owner, protectedBird, near, AccessLevelType.PUBLIC); + + em.flush(); + em.clear(); + + // when + List result = collectionRepository.findNearbyEven( + ref, + 1_000, + null, + false, + 10, + 80 + ); + + // then + assertEquals(1, result.size()); + assertTrue(result.stream().anyMatch(c -> c.getId().equals(normalCollection.getId()))); + assertFalse(result.stream().anyMatch(c -> c.getId().equals(protectedCollection.getId()))); + } + + @Test + @DisplayName("findNearbyEven mineOnly는 내 보호등급 새 컬렉션도 결과에 포함한다") + void findNearbyEven_mineOnly_includesMyProtectedBirdCollections() throws Exception { + // given + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + + Bird protectedBird = new BirdBuilder(em) + .korName("protected-even-mine-nearby-" + System.nanoTime()) + .conservationGrade(ConservationGrade.GRADE_I) + .build(); + + UserBirdCollection protectedCollection = newCollection(owner, protectedBird, near, AccessLevelType.PRIVATE); + + em.flush(); + em.clear(); + + // when + List result = collectionRepository.findNearbyEven( + ref, + 1_000, + owner.getId(), + true, + 10, + 80 + ); + + // then + assertEquals(1, result.size()); + assertTrue(result.stream().anyMatch(c -> c.getId().equals(protectedCollection.getId()))); + } } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMaskerTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMaskerTest.java new file mode 100644 index 00000000..4cb510b0 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/service/CollectionLocationMaskerTest.java @@ -0,0 +1,78 @@ +package org.devkor.apu.saerok_server.domain.collection.core.service; + +import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +class CollectionLocationMaskerTest { + + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); + + @Test + @DisplayName("보호등급 새 컬렉션은 소유자가 아닌 사용자에게 위치를 숨긴다") + void protectedBird_nonOwner_masksLocation() { + UserBirdCollection collection = collectionWithBird(ConservationGrade.GRADE_I); + + assertThat(CollectionLocationMasker.shouldMaskLocation(collection, false)).isTrue(); + assertThat(CollectionLocationMasker.latitude(collection, false)).isNull(); + assertThat(CollectionLocationMasker.longitude(collection, false)).isNull(); + assertThat(CollectionLocationMasker.locationAlias(collection, false)).isNull(); + assertThat(CollectionLocationMasker.address(collection, false)).isNull(); + } + + @Test + @DisplayName("보호등급 새 컬렉션도 소유자에게는 원본 위치를 보여준다") + void protectedBird_owner_keepsLocation() { + UserBirdCollection collection = collectionWithBird(ConservationGrade.GRADE_II); + + assertThat(CollectionLocationMasker.shouldMaskLocation(collection, true)).isFalse(); + assertThat(CollectionLocationMasker.latitude(collection, true)).isEqualTo(37.5665); + assertThat(CollectionLocationMasker.longitude(collection, true)).isEqualTo(126.9780); + assertThat(CollectionLocationMasker.locationAlias(collection, true)).isEqualTo("서울광장"); + assertThat(CollectionLocationMasker.address(collection, true)).isEqualTo("서울 중구"); + } + + @Test + @DisplayName("보호등급이 없는 새 컬렉션은 소유자가 아니어도 위치를 보여준다") + void unprotectedBird_nonOwner_keepsLocation() { + UserBirdCollection collection = collectionWithBird(ConservationGrade.NONE); + + assertThat(CollectionLocationMasker.shouldMaskLocation(collection, false)).isFalse(); + assertThat(CollectionLocationMasker.latitude(collection, false)).isEqualTo(37.5665); + assertThat(CollectionLocationMasker.longitude(collection, false)).isEqualTo(126.9780); + assertThat(CollectionLocationMasker.locationAlias(collection, false)).isEqualTo("서울광장"); + assertThat(CollectionLocationMasker.address(collection, false)).isEqualTo("서울 중구"); + } + + @Test + @DisplayName("bird가 아직 없는 동정 요청 컬렉션은 위치를 숨기지 않는다") + void pendingCollection_keepsLocation() { + UserBirdCollection collection = collectionWithBird(null); + + assertThat(CollectionLocationMasker.shouldMaskLocation(collection, false)).isFalse(); + assertThat(CollectionLocationMasker.latitude(collection, false)).isEqualTo(37.5665); + assertThat(CollectionLocationMasker.longitude(collection, false)).isEqualTo(126.9780); + } + + private static UserBirdCollection collectionWithBird(ConservationGrade grade) { + UserBirdCollection collection = new UserBirdCollection(); + collection.setLocation(GEOMETRY_FACTORY.createPoint(new Coordinate(126.9780, 37.5665))); + collection.setLocationAlias("서울광장"); + collection.setAddress("서울 중구"); + + if (grade != null) { + Bird bird = new Bird(); + ReflectionTestUtils.setField(bird, "conservationGrade", grade); + ReflectionTestUtils.setField(collection, "bird", bird); + } + + return collection; + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/BirdConservationGradeMigrationTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/BirdConservationGradeMigrationTest.java new file mode 100644 index 00000000..8525b010 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/BirdConservationGradeMigrationTest.java @@ -0,0 +1,35 @@ +package org.devkor.apu.saerok_server.domain.dex.bird; + +import jakarta.persistence.EntityManager; +import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class BirdConservationGradeMigrationTest extends AbstractPostgresContainerTest { + + @Autowired + EntityManager em; + + @Test + @DisplayName("Flyway 보호등급 데이터가 현재 도감 seed 기준 카운트와 일치한다") + void conservationGradeCounts_matchSpreadsheet() { + Object[] row = (Object[]) em.createNativeQuery(""" + SELECT + COUNT(*) FILTER (WHERE conservation_grade = 'GRADE_I'), + COUNT(*) FILTER (WHERE conservation_grade = 'GRADE_II'), + COUNT(*) FILTER (WHERE conservation_grade = 'NONE') + FROM bird + """).getSingleResult(); + + assertThat(((Number) row[0]).longValue()).isEqualTo(16L); + assertThat(((Number) row[1]).longValue()).isEqualTo(58L); + assertThat(((Number) row[2]).longValue()).isEqualTo(512L); + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdRepositoryTest.java index 6ec49a1c..1552a6ae 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/core/repository/BirdRepositoryTest.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.dex.bird.core.repository; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.domain.dex.bird.query.dto.BirdSearchDto; import org.devkor.apu.saerok_server.domain.dex.bird.query.dto.CmRangeDto; import org.devkor.apu.saerok_server.domain.dex.bird.query.enums.BirdSearchSortDirType; @@ -46,6 +47,14 @@ void findById_returnsActiveBird() { assertThat(found.get().getId()).isEqualTo(bird.getId()); } + @Test @DisplayName("findById returns conservation grade") + void findById_returnsConservationGrade() { + Optional found = repo.findById(600L); + + assertThat(found).isPresent(); + assertThat(found.get().getConservationGrade()).isEqualTo(ConservationGrade.GRADE_I); + } + @Test @DisplayName("findById - soft delete 된 새 제외") void findById_excludesSoftDeleted() { Bird bird = newBird("deleted-bird-" + System.nanoTime(), 10.0); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/repository/BirdProfileViewRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/repository/BirdProfileViewRepositoryTest.java index 03ce3cc8..29acb471 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/repository/BirdProfileViewRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/repository/BirdProfileViewRepositoryTest.java @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.dex.bird.query.repository; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdRepository; import org.devkor.apu.saerok_server.domain.dex.bird.query.view.BirdProfileView; import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; @@ -47,6 +48,16 @@ public class BirdProfileViewRepositoryTest extends AbstractPostgresContainerTest System.out.println(birdProfile.get().toSummaryString()); } + @Test + void 새프로필에_보호등급이_포함된다() { + // when + Optional birdProfile = birdProfileRepository.findById(600L); + + // then + assertTrue(birdProfile.isPresent()); + assertEquals(ConservationGrade.GRADE_I, birdProfile.get().getConservationGrade()); + } + @Test void 새프로필_전부_조회하기() { // when diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/builder/BirdProfileViewTestBuilder.java b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/builder/BirdProfileViewTestBuilder.java index 712c8bdd..beb0b094 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/builder/BirdProfileViewTestBuilder.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/dex/bird/query/view/builder/BirdProfileViewTestBuilder.java @@ -3,6 +3,7 @@ import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdDescription; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdName; import org.devkor.apu.saerok_server.domain.dex.bird.query.view.BirdProfileView; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdTaxonomy; import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.HabitatType; import org.springframework.test.util.ReflectionTestUtils; @@ -17,6 +18,7 @@ public class BirdProfileViewTestBuilder { private BirdDescription description = new BirdDescription(); private Double bodyLengthCm = 25.0; private String nibrUrl = null; + private ConservationGrade conservationGrade = ConservationGrade.NONE; private List habitats = List.of(); private List seasonsWithRarity = List.of(); private List images = List.of(); @@ -30,6 +32,7 @@ public BirdProfileView build() { ReflectionTestUtils.setField(birdProfileView, "description", description); ReflectionTestUtils.setField(birdProfileView, "bodyLengthCm", bodyLengthCm); ReflectionTestUtils.setField(birdProfileView, "nibrUrl", nibrUrl); + ReflectionTestUtils.setField(birdProfileView, "conservationGrade", conservationGrade); ReflectionTestUtils.setField(birdProfileView, "habitats", habitats); ReflectionTestUtils.setField(birdProfileView, "seasonsWithRarity", seasonsWithRarity); ReflectionTestUtils.setField(birdProfileView, "images", images); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationWorkerTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationWorkerTest.java new file mode 100644 index 00000000..7f1bde4a --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/freeboard/application/event/FreeBoardNotificationWorkerTest.java @@ -0,0 +1,176 @@ +package org.devkor.apu.saerok_server.domain.freeboard.application.event; + +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotificationPublisher; +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.port.TargetMetadataPort; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class FreeBoardNotificationWorkerTest { + + @Mock private NotificationPublisher publisher; + + private FreeBoardNotificationWorker worker; + + @BeforeEach + void setUp() { + TargetMetadataPort metadataPort = (target, baseExtras) -> { + Map extras = baseExtras == null ? new HashMap<>() : new HashMap<>(baseExtras); + + if (target.type() == TargetType.FREE_BOARD_POST) { + extras.put("freeBoardPostId", target.id()); + } else if (target.type() == TargetType.FREE_BOARD_COMMENT) { + extras.put("freeBoardCommentId", target.id()); + extras.put("freeBoardPostId", 100L); + } + return extras; + }; + + worker = new FreeBoardNotificationWorker(new NotifyActionDsl(publisher, metadataPort)); + } + + @Test + @DisplayName("원댓글 알림은 게시글 소유자에게 하나 생성된다") + void handle_topLevelComment_generatesPostOwnerNotification() { + worker.handle(new FreeBoardNotificationEvent.CommentCreated( + 1L, "commenter", + 100L, 2L, + null, null, + "hello" + )); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(NotificationPayload.class); + verify(publisher).push(payloadCaptor.capture()); + + ActionNotificationPayload payload = (ActionNotificationPayload) payloadCaptor.getValue(); + assertThat(payload.recipientId()).isEqualTo(2L); + assertThat(payload.subject()).isEqualTo(NotificationSubject.FREE_BOARD_POST); + assertThat(payload.action()).isEqualTo(NotificationAction.COMMENT); + assertThat(payload.type()).isEqualTo(NotificationType.COMMENTED_ON_FREE_BOARD_POST); + assertThat(payload.relatedId()).isEqualTo(100L); + assertThat(payload.extras()).containsEntry("freeBoardPostId", 100L); + assertThat(payload.extras()).containsEntry("comment", "hello"); + } + + @Test + @DisplayName("자기 게시글에 원댓글을 달면 알림이 생성되지 않는다") + void handle_selfComment_skipsNotifications() { + worker.handle(new FreeBoardNotificationEvent.CommentCreated( + 1L, "owner", + 100L, 1L, + null, null, + "self comment" + )); + + verifyNoInteractions(publisher); + } + + @Test + @DisplayName("대댓글 알림은 원댓글 작성자와 게시글 소유자에게 각각 생성된다") + void handle_replyComment_generatesTwoNotifications() { + worker.handle(new FreeBoardNotificationEvent.CommentCreated( + 1L, "replier", + 100L, 3L, + 200L, 2L, + "reply body" + )); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(NotificationPayload.class); + verify(publisher, times(2)).push(payloadCaptor.capture()); + + List payloads = payloadCaptor.getAllValues().stream() + .map(ActionNotificationPayload.class::cast) + .toList(); + + assertThat(payloads) + .extracting(ActionNotificationPayload::recipientId, ActionNotificationPayload::type) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple(2L, NotificationType.REPLIED_TO_FREE_BOARD_COMMENT), + org.assertj.core.groups.Tuple.tuple(3L, NotificationType.COMMENTED_ON_FREE_BOARD_POST) + ); + + ActionNotificationPayload replyPayload = payloads.stream() + .filter(p -> p.type() == NotificationType.REPLIED_TO_FREE_BOARD_COMMENT) + .findFirst() + .orElseThrow(); + + assertThat(replyPayload.subject()).isEqualTo(NotificationSubject.FREE_BOARD_COMMENT); + assertThat(replyPayload.action()).isEqualTo(NotificationAction.REPLY); + assertThat(replyPayload.relatedId()).isEqualTo(100L); + assertThat(replyPayload.extras()).containsEntry("freeBoardCommentId", 200L); + assertThat(replyPayload.extras()).containsEntry("freeBoardPostId", 100L); + assertThat(replyPayload.extras()).containsEntry("comment", "reply body"); + } + + @Test + @DisplayName("대댓글: 게시글 소유자가 원댓글 작성자와 같으면 게시글 소유자 알림은 중복으로 생성하지 않는다") + void handle_replyWhenPostOwnerIsParentAuthor_generatesOnlyReplyNotification() { + worker.handle(new FreeBoardNotificationEvent.CommentCreated( + 1L, "replier", + 100L, 2L, + 200L, 2L, + "reply body" + )); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(NotificationPayload.class); + verify(publisher).push(payloadCaptor.capture()); + + ActionNotificationPayload payload = (ActionNotificationPayload) payloadCaptor.getValue(); + assertThat(payload.recipientId()).isEqualTo(2L); + assertThat(payload.type()).isEqualTo(NotificationType.REPLIED_TO_FREE_BOARD_COMMENT); + } + + @Test + @DisplayName("대댓글: 본인이 자기 댓글에 자답해도 게시글 소유자에게는 알림이 간다") + void handle_selfReplyOnOwnComment_stillNotifiesPostOwner() { + worker.handle(new FreeBoardNotificationEvent.CommentCreated( + 1L, "self", + 100L, 3L, + 200L, 1L, + "self reply" + )); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(NotificationPayload.class); + verify(publisher).push(payloadCaptor.capture()); + + ActionNotificationPayload payload = (ActionNotificationPayload) payloadCaptor.getValue(); + assertThat(payload.recipientId()).isEqualTo(3L); + assertThat(payload.type()).isEqualTo(NotificationType.COMMENTED_ON_FREE_BOARD_POST); + } + + @Test + @DisplayName("발송 중 예외가 나도 워커는 예외를 외부로 전파하지 않는다") + void handle_publisherFailure_swallowsException() { + doThrow(new IllegalStateException("push failed")).when(publisher).push(org.mockito.ArgumentMatchers.any()); + + assertThatCode(() -> worker.handle(new FreeBoardNotificationEvent.CommentCreated( + 1L, "commenter", + 100L, 2L, + null, null, + "boom" + ))).doesNotThrowAnyException(); + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationSettingQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationSettingQueryServiceTest.java new file mode 100644 index 00000000..9ae946f0 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationSettingQueryServiceTest.java @@ -0,0 +1,96 @@ +package org.devkor.apu.saerok_server.domain.notification.application; + +import org.devkor.apu.saerok_server.domain.notification.api.dto.response.NotificationSettingsResponse; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSetting; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice; +import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationSettingRepository; +import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; +import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationSettingBackfillService; +import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationTypeSchema; +import org.devkor.apu.saerok_server.domain.notification.mapper.NotificationSettingWebMapper; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; +import org.devkor.apu.saerok_server.testsupport.builder.UserBuilder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import({ + NotificationSettingQueryService.class, + NotificationSettingBackfillService.class, + NotificationTypeSchema.class, + NotificationSettingRepository.class, + UserDeviceRepository.class, + NotificationSettingWebMapper.class +}) +@ActiveProfiles("test") +class NotificationSettingQueryServiceTest extends AbstractPostgresContainerTest { + + @Autowired TestEntityManager em; + @Autowired NotificationSettingQueryService service; + @Autowired UserDeviceRepository userDeviceRepository; + @Autowired NotificationSettingRepository settingRepository; + @Autowired PlatformTransactionManager transactionManager; + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("설정 조회 시 누락된 자유게시판 알림 타입까지 기본 설정으로 백필한다") + void getNotificationSettings_backfillsMissingTypes() { + Long userId = new TransactionTemplate(transactionManager).execute(status -> { + User user = new UserBuilder(em).build(); + UserDevice device = UserDevice.create(user, "device-1", "token-1", DevicePlatform.IOS); + userDeviceRepository.save(device); + userDeviceRepository.flush(); + + settingRepository.save(NotificationSetting.of(device, NotificationType.LIKED_ON_COLLECTION, true)); + em.flush(); + em.clear(); + return user.getId(); + }); + + try { + NotificationSettingsResponse response = + service.getNotificationSettings(userId, "device-1", DevicePlatform.IOS); + NotificationSettingsResponse secondResponse = + service.getNotificationSettings(userId, "device-1", DevicePlatform.IOS); + + assertThat(response.items()).hasSize(NotificationType.values().length); + assertThat(secondResponse.items()).hasSize(NotificationType.values().length); + assertThat(response.items()) + .extracting(NotificationSettingsResponse.Item::type) + .containsAll(Arrays.asList(NotificationType.values())); + assertThat(response.items()) + .filteredOn(item -> item.type() == NotificationType.COMMENTED_ON_FREE_BOARD_POST + || item.type() == NotificationType.REPLIED_TO_FREE_BOARD_COMMENT) + .extracting(NotificationSettingsResponse.Item::enabled) + .containsOnly(true); + } finally { + new TransactionTemplate(transactionManager).execute(status -> { + settingRepository.deleteByUserId(userId); + userDeviceRepository.deleteByUserId(userId); + + User user = em.find(User.class, userId); + if (user != null) { + em.remove(user); + } + em.flush(); + return null; + }); + } + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/BirdBuilder.java b/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/BirdBuilder.java index f1df3539..07ea8535 100644 --- a/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/BirdBuilder.java +++ b/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/BirdBuilder.java @@ -4,6 +4,7 @@ import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdDescription; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdName; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdTaxonomy; +import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; @@ -17,6 +18,7 @@ public class BirdBuilder { private BirdTaxonomy taxonomy; private Double bodyLengthCm = 25.0; private String nibrUrl = null; + private ConservationGrade conservationGrade = ConservationGrade.NONE; public BirdBuilder(TestEntityManager em) { this.em = em; @@ -56,6 +58,11 @@ public BirdBuilder thumbnailUrl(String url) { return this; } + public BirdBuilder conservationGrade(ConservationGrade conservationGrade) { + this.conservationGrade = conservationGrade; + return this; + } + /** * Builds and persists the Bird. */ @@ -78,6 +85,7 @@ public Bird build() { // additional fields ReflectionTestUtils.setField(bird, "bodyLengthCm", bodyLengthCm); ReflectionTestUtils.setField(bird, "nibrUrl", nibrUrl); + ReflectionTestUtils.setField(bird, "conservationGrade", conservationGrade); em.persist(bird); em.flush(); diff --git a/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/CollectionBuilder.java b/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/CollectionBuilder.java index 4e716fa9..06e6f18f 100644 --- a/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/CollectionBuilder.java +++ b/src/test/java/org/devkor/apu/saerok_server/testsupport/builder/CollectionBuilder.java @@ -2,6 +2,7 @@ import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; +import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; @@ -17,6 +18,7 @@ public class CollectionBuilder { private final TestEntityManager em; private User owner; + private Bird bird; private AccessLevelType accessLevel = AccessLevelType.PUBLIC; private LocalDate discoveredDate = LocalDate.now(); private Point location = new GeometryFactory() @@ -31,6 +33,11 @@ public CollectionBuilder owner(User owner) { return this; } + public CollectionBuilder bird(Bird bird) { + this.bird = bird; + return this; + } + public CollectionBuilder accessLevel(AccessLevelType level) { this.accessLevel = level; return this; @@ -53,6 +60,7 @@ public UserBirdCollection build() { UserBirdCollection coll = new UserBirdCollection(); // inject owner ReflectionTestUtils.setField(coll, "user", owner); + ReflectionTestUtils.setField(coll, "bird", bird); coll.setAccessLevel(accessLevel); coll.setDiscoveredDate(discoveredDate); coll.setLocation(location);