HighlightService.java

package qwerty.chaekit.service.highlight;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import qwerty.chaekit.domain.ebook.Ebook;
import qwerty.chaekit.domain.group.activity.Activity;
import qwerty.chaekit.domain.group.activity.discussion.Discussion;
import qwerty.chaekit.domain.group.activity.discussion.highlight.DiscussionHighlight;
import qwerty.chaekit.domain.group.activity.discussion.highlight.DiscussionHighlightRepository;
import qwerty.chaekit.domain.highlight.Highlight;
import qwerty.chaekit.domain.highlight.repository.HighlightRepository;
import qwerty.chaekit.domain.member.user.UserProfile;
import qwerty.chaekit.dto.highlight.*;
import qwerty.chaekit.dto.page.PageResponse;
import qwerty.chaekit.global.enums.ErrorCode;
import qwerty.chaekit.global.exception.BadRequestException;
import qwerty.chaekit.global.exception.ForbiddenException;
import qwerty.chaekit.global.security.resolver.UserToken;
import qwerty.chaekit.service.ebook.EbookPolicy;
import qwerty.chaekit.service.group.ActivityPolicy;
import qwerty.chaekit.service.util.EntityFinder;
import qwerty.chaekit.service.util.FileService;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class HighlightService {
    private final HighlightRepository highlightRepository;
    private final DiscussionHighlightRepository discussionHighlightRepository;
    private final ActivityPolicy activityPolicy;
    private final HighlightPolicy highlightPolicy;
    private final EntityFinder entityFinder;
    private final FileService fileService;
    private final EbookPolicy ebookPolicy;

    public HighlightPostResponse createHighlight(UserToken userToken, HighlightPostRequest request) {
        UserProfile user = entityFinder.findUser(userToken.userId());
        Ebook ebook = entityFinder.findEbook(request.bookId());
        String spine = request.spine();
        String cfi = request.cfi();
        String memo = request.memo();
        Long activityId = request.activityId();
        boolean isPublic = activityId!= null;
        
        Activity activity;
        if (isPublic) {
            activity = entityFinder.findActivity(request.activityId());
            activityPolicy.assertJoined(user, activity);
        } else {
            activity = null;
            ebookPolicy.assertEBookPurchased(user, ebook);
        }
        
        Highlight highlight = Highlight.builder()
                .author(user)
                .book(ebook)
                .spine(spine)
                .cfi(cfi)
                .memo(memo)
                .isPublic(isPublic)
                .activity(activity)
                .highlightcontent(request.highlightContent())
                .build();
        
        return HighlightPostResponse.of(highlightRepository.save(highlight));
    }

    public PageResponse<HighlightFetchResponse> getMyHighlights(UserToken userToken, @Nullable Long bookId, String keyword, Pageable pageable) {
        UserProfile user = entityFinder.findUser(userToken.userId());
        Page<Highlight> highlights = highlightRepository.findByAuthor(user, bookId, keyword, pageable);

        Map<Long, List<Discussion>> relatedDiscussionMap = getRelatedDiscussionMap(highlights);

        return PageResponse.of(highlights.map(
                highlight -> HighlightFetchResponse.of(
                        highlight,
                        fileService.convertToPublicImageURL(highlight.getAuthor().getProfileImageKey()),
                        fileService.convertToPublicImageURL(highlight.getBook().getCoverImageKey()),
                        highlight.getActivity() != null ?
                            fileService.convertToPublicImageURL(highlight.getActivity().getGroup().getGroupImageKey()) : null,
                        relatedDiscussionMap.getOrDefault(highlight.getId(), List.of())
                )
        ));
    }

    public PageResponse<HighlightFetchResponse> fetchHighlights(UserToken userToken, 
                                                                Pageable pageable, 
                                                                Long activityId, 
                                                                Long bookId, 
                                                                String spine, 
                                                                boolean me, 
                                                                String keyword
    ) {
        boolean isFetchingByActivity = activityId != null;
        boolean isFetchingBySpineButBookIdIsNull = spine != null && bookId == null;
        boolean isFetchingPublicHighlight = !me;
        
        if (isFetchingBySpineButBookIdIsNull) {
            throw new BadRequestException(ErrorCode.BOOK_ID_REQUIRED);
        }
        
        if (isFetchingByActivity) {
            activityPolicy.assertJoined(userToken.userId(), activityId);
        } else if (isFetchingPublicHighlight) {
            throw new BadRequestException(ErrorCode.ACTIVITY_ID_REQUIRED);
        }

        // 조회 조건에 맞는 하이라이트를 가져옴

        Page<Highlight> highlights = highlightRepository.findHighlights(pageable, userToken.userId(), activityId, bookId, spine, me, keyword);

        Map<Long, List<Discussion>> relatedDiscussionMap = getRelatedDiscussionMap(highlights);

        return PageResponse.of(highlights.map(
                highlight -> HighlightFetchResponse.of(
                        highlight,
                        fileService.convertToPublicImageURL(highlight.getAuthor().getProfileImageKey()),
                        fileService.convertToPublicImageURL(highlight.getBook().getCoverImageKey()),
                        highlight.getActivity() != null ?
                                fileService.convertToPublicImageURL(highlight.getActivity().getGroup().getGroupImageKey()) : null,
                        relatedDiscussionMap.getOrDefault(highlight.getId(), List.of())
                )
        ));
    }

    private Map<Long, List<Discussion>> getRelatedDiscussionMap(Page<Highlight> highlights) {
        // 1. highlightId 추출
        List<Long> highlightIds = highlights.stream()
                .map(Highlight::getId)
                .toList();

        // 2. 연관된 DiscussionHighlight를 모두 조회
        List<DiscussionHighlight> discussionLinks = discussionHighlightRepository.findByHighlightIdIn(highlightIds);

        // 3. highlightId → List<Discussion> 매핑
        return discussionLinks.stream()
                .collect(Collectors.groupingBy(
                        dh -> dh.getHighlight().getId(),
                        Collectors.mapping(DiscussionHighlight::getDiscussion, Collectors.toList())
                ));
    }

    @Transactional(readOnly = true)
    public List<HighlightPreviewResponse> getActivityRecentHighlights(Long activityId) {
        Activity activity = entityFinder.findActivity(activityId);
        List<Highlight> highlights = highlightRepository.findRecentByActivity(activity);

        return highlights.stream()
                .map(highlight -> HighlightPreviewResponse.of(
                        highlight,
                        fileService.convertToPublicImageURL(highlight.getAuthor().getProfileImageKey())
                ))
                .toList();
    }

    @Transactional
    public HighlightPostResponse updateHighlight(UserToken userToken, Long id, HighlightPutRequest request) {
        Long newActivityId = request.activityId();
        String newMemo = request.memo();
        
        UserProfile user = entityFinder.findUser(userToken.userId());
        Highlight highlight = entityFinder.findHighlight(id);

        highlightPolicy.assertUpdatable(user, highlight);
        
        if(newActivityId != null) {
            Activity activity = entityFinder.findActivity(newActivityId);
            activityPolicy.assertJoined(user, activity);
            highlight.setAsPublicActivity(activity);
        }

        highlight.updateMemo(newMemo);
        return HighlightPostResponse.of(highlightRepository.save(highlight));
    }

    public void deleteHighlight(UserToken userToken, Long id) {
        UserProfile user = entityFinder.findUser(userToken.userId());
        Highlight highlight = entityFinder.findHighlight(id);

        highlightPolicy.assertUpdatable(user, highlight);

        highlightRepository.delete(highlight);
    }

    public HighlightFetchResponse fetchHighlight(UserToken userToken, Long id) {
        UserProfile user = entityFinder.findUser(userToken.userId());
        Highlight highlight = entityFinder.findHighlight(id);

        if (!highlight.isAuthor(user) && !highlight.isPublic()) {
            throw new ForbiddenException(ErrorCode.HIGHLIGHT_NOT_SEE);
        }
        List<Discussion> discussionLinks = discussionHighlightRepository.findByHighlight(highlight)
                .stream()
                .map(DiscussionHighlight::getDiscussion).toList();
        String authorProfileImageURL = fileService.convertToPublicImageURL(highlight.getAuthor().getProfileImageKey());
        String bookCoverImageURL = fileService.convertToPublicImageURL(highlight.getBook().getCoverImageKey());
        String groupImageURL = highlight.getActivity() != null ?
                fileService.convertToPublicImageURL(highlight.getActivity().getGroup().getGroupImageKey()) : null;
        return HighlightFetchResponse.of(highlight, authorProfileImageURL, bookCoverImageURL, groupImageURL, discussionLinks);
    }
}