Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions src/main/java/com/dokdok/meeting/dto/MeetingDetailResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,19 @@ public static MeetingDetailResponse from(
List<MeetingMember> meetingMembers,
Long requestUserId,
Boolean confirmedTopic,
LocalDateTime confirmedTopicDate
LocalDateTime confirmedTopicDate,
Map<Long, String> profileImageUrlMap
) {
List<MeetingMember> safeMembers = meetingMembers == null ? Collections.emptyList() : meetingMembers;
List<MeetingMember> activeMembers = safeMembers.stream()
.filter(member -> member.getCanceledAt() == null)
.toList();

ParticipantsInfo participantsInfo = ParticipantsInfo.from(activeMembers, meeting.getMaxParticipants());
ParticipantsInfo participantsInfo = ParticipantsInfo.from(
activeMembers,
meeting.getMaxParticipants(),
profileImageUrlMap
);

ActionState actionState = calculateActionState(
meeting,
Expand Down Expand Up @@ -270,9 +275,13 @@ public record ParticipantsInfo(
@Schema(description = "참가자 목록")
List<MemberInfo> members
) {
public static ParticipantsInfo from(List<MeetingMember> activeMembers, Integer maxCount) {
public static ParticipantsInfo from(
List<MeetingMember> activeMembers,
Integer maxCount,
Map<Long, String> profileImageUrlMap
) {
List<MemberInfo> members = activeMembers.stream()
.map(MemberInfo::from)
.map(member -> MemberInfo.from(member, profileImageUrlMap))
.toList();

return new ParticipantsInfo(members.size(), maxCount, members);
Expand All @@ -293,17 +302,29 @@ public record MemberInfo(
@Schema(description = "참가자 역할", example = "LEADER")
MeetingMemberRole role
) {
public static MemberInfo from(MeetingMember meetingMember) {
public static MemberInfo from(MeetingMember meetingMember, Map<Long, String> profileImageUrlMap) {
User user = meetingMember.getUser();
String profileImageUrl = resolveProfileImageUrl(user, profileImageUrlMap);
return new MemberInfo(
user.getId(),
user.getNickname(),
user.getProfileImageUrl(),
profileImageUrl,
meetingMember.getMeetingRole()
);
}
}

private static String resolveProfileImageUrl(User user, Map<Long, String> profileImageUrlMap) {
if (user == null) {
return null;
}
if (profileImageUrlMap == null) {
return user.getProfileImageUrl();
}
String presignedUrl = profileImageUrlMap.get(user.getId());
return presignedUrl != null ? presignedUrl : user.getProfileImageUrl();
}

@Schema(description = "화면 버튼 상태")
public record ActionState(
@Schema(description = """
Expand Down
27 changes: 22 additions & 5 deletions src/main/java/com/dokdok/meeting/dto/MeetingResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import java.time.LocalTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;

@Schema(description = "약속 응답")
public record MeetingResponse(
Expand Down Expand Up @@ -52,7 +53,7 @@ public static MeetingResponse from(Meeting meeting, List<MeetingMember> meetingM
BookInfo.from(meeting.getBook()),
ScheduleInfo.from(meeting.getMeetingStartDate(), meeting.getMeetingEndDate()),
MeetingLocationDto.from(meeting.getLocation()),
ParticipantsInfo.from(safeMembers, meeting.getMaxParticipants())
ParticipantsInfo.from(safeMembers, meeting.getMaxParticipants(), null)
);
}

Expand Down Expand Up @@ -126,10 +127,14 @@ public record ParticipantsInfo(
@Schema(description = "참가자 목록")
List<MemberInfo> members
) {
public static ParticipantsInfo from(List<MeetingMember> meetingMembers, Integer maxCount) {
public static ParticipantsInfo from(
List<MeetingMember> meetingMembers,
Integer maxCount,
Map<Long, String> profileImageUrlMap
) {
List<MemberInfo> members = meetingMembers.stream()
.filter(member -> member.getCanceledAt() == null)
.map(MemberInfo::from)
.map(member -> MemberInfo.from(member, profileImageUrlMap))
.toList();

return new ParticipantsInfo(members.size(), maxCount, members);
Expand All @@ -147,9 +152,21 @@ public record MemberInfo(
@Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg")
String profileImageUrl
) {
public static MemberInfo from(MeetingMember meetingMember) {
public static MemberInfo from(MeetingMember meetingMember, Map<Long, String> profileImageUrlMap) {
User user = meetingMember.getUser();
return new MemberInfo(user.getId(), user.getNickname(), user.getProfileImageUrl());
String profileImageUrl = resolveProfileImageUrl(user, profileImageUrlMap);
return new MemberInfo(user.getId(), user.getNickname(), profileImageUrl);
}
}

private static String resolveProfileImageUrl(User user, Map<Long, String> profileImageUrlMap) {
if (user == null) {
return null;
}
if (profileImageUrlMap == null) {
return user.getProfileImageUrl();
}
String presignedUrl = profileImageUrlMap.get(user.getId());
return presignedUrl != null ? presignedUrl : user.getProfileImageUrl();
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/dokdok/meeting/repository/MeetingRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ public interface MeetingRepository extends JpaRepository<Meeting, Long> {

boolean existsByGatheringIdAndMeetingStatus(Long gatheringId, MeetingStatus meetingStatus);

@Query("""
SELECT CASE WHEN COUNT(m) > 0 THEN true ELSE false END
FROM Meeting m
WHERE m.gathering.id = :gatheringId
AND m.meetingStatus = :meetingStatus
AND m.id <> :meetingId
AND m.meetingStartDate < :endDate
AND m.meetingEndDate > :startDate
""")
boolean existsOverlappingMeeting(
@Param("gatheringId") Long gatheringId,
@Param("meetingStatus") MeetingStatus meetingStatus,
@Param("meetingId") Long meetingId,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate
);

int countByGatheringIdAndMeetingStatus(Long gatheringId, MeetingStatus meetingStatus);

@EntityGraph(attributePaths = {"book"})
Expand Down
44 changes: 39 additions & 5 deletions src/main/java/com/dokdok/meeting/service/MeetingService.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import com.dokdok.topic.repository.TopicAnswerRepository;
import com.dokdok.topic.repository.TopicRepository;
import com.dokdok.topic.service.TopicService;
import com.dokdok.storage.service.StorageService;
import com.dokdok.user.entity.User;
import com.dokdok.user.service.UserValidator;
import lombok.RequiredArgsConstructor;
Expand All @@ -52,6 +53,7 @@ public class MeetingService {
private final TopicRepository topicRepository;
private final TopicAnswerRepository topicAnswerRepository;
private final TopicService topicService;
private final StorageService storageService;
private final GatheringRepository gatheringRepository;
private final GatheringMemberRepository gatheringMemberRepository;
private final GatheringValidator gatheringValidator;
Expand All @@ -76,6 +78,7 @@ public MeetingDetailResponse findMeeting(Long meetingId) {
gatheringValidator.validateMembership(meeting.getGathering().getId(), userId);

List<MeetingMember> meetingMembers = meetingMemberRepository.findAllByMeetingId(meetingId);
Map<Long, String> profileImageUrlMap = buildProfileImageUrlMap(meetingMembers);

LocalDateTime confirmedTopicDate = topicRepository.findConfirmedTopicDateByMeetingId(
meetingId,
Expand All @@ -88,10 +91,30 @@ public MeetingDetailResponse findMeeting(Long meetingId) {
meetingMembers,
userId,
confirmedTopic,
confirmedTopicDate
confirmedTopicDate,
profileImageUrlMap
);
}

/**
* 약속 멤버들의 프로필 이미지 presigned URL Map 생성
*/
private Map<Long, String> buildProfileImageUrlMap(List<MeetingMember> members) {
Map<Long, String> profileImageUrlMap = new HashMap<>();

members.forEach(member -> {
User user = member.getUser();
if (user == null || user.getId() == null) {
return;
}
String profileImageUrl = user.getProfileImageUrl();
String presignedUrl = storageService.getPresignedProfileImage(profileImageUrl);
profileImageUrlMap.put(user.getId(), presignedUrl);
});

return profileImageUrlMap;
}

/**
* 모임원이 약속 생성 신청을 할 수 있다. 모임에 속한 사용자만 생성 가능
* @param request 약속 생성 신청 폼
Expand Down Expand Up @@ -206,11 +229,22 @@ private void validateMaxParticipants(Integer maxParticipants, Long gatheringId)
*/
private void validateConfirmable(Meeting meeting) {
Long gatheringId = meeting.getGathering().getId();
boolean hasConfirmedMeeting = meetingRepository
.existsByGatheringIdAndMeetingStatus(gatheringId, MeetingStatus.CONFIRMED);
if (hasConfirmedMeeting) {
LocalDateTime startDate = meeting.getMeetingStartDate();
LocalDateTime endDate = meeting.getMeetingEndDate();
if (startDate == null || endDate == null) {
throw new MeetingException(MeetingErrorCode.MEETING_DATE_REQUIRED,
"약속 시작/종료 일시는 필수입니다.");
}
boolean hasOverlappingMeeting = meetingRepository.existsOverlappingMeeting(
gatheringId,
MeetingStatus.CONFIRMED,
meeting.getId(),
startDate,
endDate
);
if (hasOverlappingMeeting) {
throw new MeetingException(MeetingErrorCode.INVALID_MEETING_STATUS_CHANGE,
"이미 확정된 약속이 존재합니다.");
"동일 시간에 확정된 약속이 존재합니다.");
}
}

Expand Down
69 changes: 65 additions & 4 deletions src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import com.dokdok.topic.repository.TopicAnswerRepository;
import com.dokdok.topic.repository.TopicRepository;
import com.dokdok.topic.service.TopicService;
import com.dokdok.storage.service.StorageService;
import com.dokdok.user.entity.User;
import com.dokdok.user.service.UserValidator;
import org.junit.jupiter.api.BeforeEach;
Expand Down Expand Up @@ -72,6 +73,9 @@ class MeetingServiceTest {
@Mock
private TopicService topicService;

@Mock
private StorageService storageService;

@Mock
private GatheringRepository gatheringRepository;

Expand Down Expand Up @@ -219,6 +223,43 @@ void givenMeetingDates_whenFindMeeting_thenProgressStatus() {
}
}

@DisplayName("약속 상세 조회 시 멤버 프로필 이미지는 presigned URL로 내려간다.")
@Test
void givenMeetingMembers_whenFindMeeting_thenUsePresignedProfileImage() {
// given
Long userId = 1L;
User memberUser = User.builder()
.id(2L)
.nickname("member")
.profileImageUrl("profiles/2/profile.jpg")
.build();
MeetingMember member = MeetingMember.builder()
.meeting(meeting)
.user(memberUser)
.build();

given(meetingValidator.findMeetingOrThrow(meetingId))
.willReturn(meeting);
given(meetingMemberRepository.findAllByMeetingId(meetingId))
.willReturn(List.of(member));
given(topicRepository.findConfirmedTopicDateByMeetingId(meetingId, TopicStatus.CONFIRMED))
.willReturn(null);
given(storageService.getPresignedProfileImage(memberUser.getProfileImageUrl()))
.willReturn("https://presigned.example.com/profile.jpg");

try (MockedStatic<SecurityUtil> securityUtilMock = mockStatic(SecurityUtil.class)) {
securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId);

// when
MeetingDetailResponse response = meetingService.findMeeting(meetingId);

// then
assertThat(response.participants().members()).hasSize(1);
assertThat(response.participants().members().get(0).profileImageUrl())
.isEqualTo("https://presigned.example.com/profile.jpg");
}
}

@DisplayName("약속 상세 응답에 주제 확정 여부와 날짜가 내려간다.")
@Test
void givenConfirmedTopicDate_whenFindMeeting_thenConfirmedTopicFields() {
Expand Down Expand Up @@ -546,7 +587,13 @@ void givenMeetingStatus_whenConfirm_thenMeetingStatusChange() {
Long gatheringLeaderId = 10L;

given(meetingValidator.findMeetingOrThrow(meetingId)).willReturn(meeting);
given(meetingRepository.existsByGatheringIdAndMeetingStatus(gathering.getId(), MeetingStatus.CONFIRMED))
given(meetingRepository.existsOverlappingMeeting(
gathering.getId(),
MeetingStatus.CONFIRMED,
meetingId,
meeting.getMeetingStartDate(),
meeting.getMeetingEndDate()
))
.willReturn(false);
given(meetingMemberRepository.findByMeetingIdAndUserId(meetingId, leader.getId()))
.willReturn(Optional.empty());
Expand All @@ -569,14 +616,20 @@ void givenMeetingStatus_whenConfirm_thenMeetingStatusChange() {
}
}

@DisplayName("이미 확정된 약속이 있으면 다른 약속을 확정할 수 없다.")
@DisplayName("시간이 겹치는 확정 약속이 있으면 다른 약속을 확정할 수 없다.")
@Test
void givenConfirmedMeetingExists_whenConfirm_thenThrowMeetingException() {
// given
Long meetingId = 1L;
Long gatheringLeaderId = 10L;
given(meetingValidator.findMeetingOrThrow(meetingId)).willReturn(meeting);
given(meetingRepository.existsByGatheringIdAndMeetingStatus(gathering.getId(), MeetingStatus.CONFIRMED))
given(meetingRepository.existsOverlappingMeeting(
gathering.getId(),
MeetingStatus.CONFIRMED,
meetingId,
meeting.getMeetingStartDate(),
meeting.getMeetingEndDate()
))
.willReturn(true);

try (MockedStatic<SecurityUtil> securityUtilMock = mockStatic(SecurityUtil.class)) {
Expand Down Expand Up @@ -621,10 +674,18 @@ void givenMissingLeader_whenConfirm_thenThrowMeetingException() {
.id(meetingId)
.meetingName("Meeting 1")
.meetingStatus(MeetingStatus.PENDING)
.meetingStartDate(LocalDateTime.now().plusDays(2))
.meetingEndDate(LocalDateTime.now().plusDays(2).plusHours(1))
.gathering(gathering)
.build();
given(meetingValidator.findMeetingOrThrow(meetingId)).willReturn(missingLeaderMeeting);
given(meetingRepository.existsByGatheringIdAndMeetingStatus(gathering.getId(), MeetingStatus.CONFIRMED))
given(meetingRepository.existsOverlappingMeeting(
gathering.getId(),
MeetingStatus.CONFIRMED,
meetingId,
missingLeaderMeeting.getMeetingStartDate(),
missingLeaderMeeting.getMeetingEndDate()
))
.willReturn(false);

try (MockedStatic<SecurityUtil> securityUtilMock = mockStatic(SecurityUtil.class)) {
Expand Down