diff --git a/src/main/java/org/dnd/timeet/agenda/application/AgendaService.java b/src/main/java/org/dnd/timeet/agenda/application/AgendaService.java index b172b4b..d2c597d 100644 --- a/src/main/java/org/dnd/timeet/agenda/application/AgendaService.java +++ b/src/main/java/org/dnd/timeet/agenda/application/AgendaService.java @@ -13,6 +13,8 @@ import org.dnd.timeet.agenda.dto.AgendaActionResponse; import org.dnd.timeet.agenda.dto.AgendaCreateRequest; import org.dnd.timeet.agenda.dto.AgendaInfoResponse; +import org.dnd.timeet.agenda.dto.AgendaPatchRequest; +import org.dnd.timeet.agenda.dto.AgendaPatchResponse; import org.dnd.timeet.common.exception.BadRequestError; import org.dnd.timeet.common.exception.NotFoundError; import org.dnd.timeet.common.exception.NotFoundError.ErrorCode; @@ -39,9 +41,11 @@ public Long createAgenda(Long meetingId, AgendaCreateRequest createDto, Member m Collections.singletonMap("MeetingId", "Meeting not found"))); // 회의에 참가한 멤버인지 확인 - participantRepository.findByMeetingIdAndMemberId(meetingId, member.getId()) - .orElseThrow(() -> new BadRequestError(BadRequestError.ErrorCode.VALIDATION_FAILED, - Collections.singletonMap("MemberId", "Member is not a participant of the meeting"))); + boolean isParticipantExists = participantRepository.existsByMeetingIdAndMemberId(meetingId, member.getId()); + if (!isParticipantExists) { + throw new BadRequestError(BadRequestError.ErrorCode.VALIDATION_FAILED, + Collections.singletonMap("MemberId", "Member is not a participant of the meeting")); + } Agenda agenda = createDto.toEntity(meeting); agenda = agendaRepository.save(agenda); @@ -59,6 +63,10 @@ public List findAll(Long meetingId) { } public AgendaActionResponse changeAgendaStatus(Long meetingId, Long agendaId, AgendaActionRequest actionRequest) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new NotFoundError(ErrorCode.RESOURCE_NOT_FOUND, + Collections.singletonMap("MeetingId", "Meeting not found"))); + Agenda agenda = agendaRepository.findByIdAndMeetingId(agendaId, meetingId) .orElseThrow(() -> new NotFoundError(ErrorCode.RESOURCE_NOT_FOUND, Collections.singletonMap("AgendaId", "Agenda not found"))); @@ -73,31 +81,40 @@ public AgendaActionResponse changeAgendaStatus(Long meetingId, Long agendaId, Ag Collections.singletonMap("Action", "Invalid action")); } + // 안건 시작 요청 전 첫 번째 안건이 시작되었는지 확인 + if (action == AgendaAction.START && agenda.getOrderNum() != 1) { + // 첫 번째 안건의 시작 여부 확인 + boolean isFirstAgendaStarted = agendaRepository.existsByMeetingIdAndOrderNumAndStatus( + meetingId, 1, AgendaStatus.COMPLETED); + + if (!isFirstAgendaStarted) { + throw new BadRequestError(BadRequestError.ErrorCode.WRONG_REQUEST_TRANSMISSION, + Collections.singletonMap("AgendaOrder", "First agenda has not been started yet")); + } + } + switch (action) { - case START: + case START -> { + // 첫번째 안건의 시작 시간을 회의 시작 시간으로 설정 + meeting.updateStartTimeOnFirstAgendaStart(agenda); agenda.start(); - break; - case PAUSE: - agenda.pause(); - break; - case RESUME: - agenda.resume(); - break; - case END: - agenda.complete(); - break; - case MODIFY: + } + case PAUSE -> agenda.pause(); + case RESUME -> agenda.resume(); + case END -> agenda.complete(); + case MODIFY -> { LocalTime modifiedDuration = LocalTime.parse(actionRequest.getModifiedDuration()); Duration duration = DurationUtils.convertLocalTimeToDuration(modifiedDuration); agenda.extendDuration(duration); // 회의 시간 추가 addMeetingTotalActualDuration(meetingId, duration); - break; - default: - throw new BadRequestError(BadRequestError.ErrorCode.VALIDATION_FAILED, - Collections.singletonMap("Action", "Invalid action")); + } + default -> throw new BadRequestError(BadRequestError.ErrorCode.VALIDATION_FAILED, + Collections.singletonMap("Action", "Invalid action")); } + // 변경 사항 저장 Agenda savedAgenda = agendaRepository.save(agenda); + meetingRepository.save(meeting); Duration currentDuration = savedAgenda.calculateCurrentDuration(); Duration remainingDuration = agenda.calculateRemainingTime(); @@ -146,4 +163,26 @@ public AgendaInfoResponse findAgendas(Long meetingId) { return new AgendaInfoResponse(meeting, agendaList); } + + public AgendaPatchResponse patchAgenda(Long meetingId, Long agendaId, AgendaPatchRequest patchRequest) { + // 회의 존재 여부만 확인 + boolean meetingExists = meetingRepository.existsById(meetingId); + if (!meetingExists) { + throw new NotFoundError(ErrorCode.RESOURCE_NOT_FOUND, + Collections.singletonMap("MeetingId", "Meeting not found")); + } + + Agenda agenda = agendaRepository.findByIdAndMeetingId(agendaId, meetingId) + .orElseThrow(() -> new NotFoundError(ErrorCode.RESOURCE_NOT_FOUND, + Collections.singletonMap("AgendaId", "Agenda not found"))); + if (agenda.getStatus() != AgendaStatus.PENDING) { + throw new BadRequestError(BadRequestError.ErrorCode.WRONG_REQUEST_TRANSMISSION, + Collections.singletonMap("AgendaStatus", "Agenda is not PENDING status")); + } + + agenda.update(patchRequest.getTitle(), + DurationUtils.convertLocalTimeToDuration(patchRequest.getAllocatedDuration())); + + return new AgendaPatchResponse(agenda); + } } diff --git a/src/main/java/org/dnd/timeet/agenda/controller/AgendaController.java b/src/main/java/org/dnd/timeet/agenda/controller/AgendaController.java index 13b8769..86057b1 100644 --- a/src/main/java/org/dnd/timeet/agenda/controller/AgendaController.java +++ b/src/main/java/org/dnd/timeet/agenda/controller/AgendaController.java @@ -9,6 +9,8 @@ import org.dnd.timeet.agenda.dto.AgendaActionResponse; import org.dnd.timeet.agenda.dto.AgendaCreateRequest; import org.dnd.timeet.agenda.dto.AgendaInfoResponse; +import org.dnd.timeet.agenda.dto.AgendaPatchRequest; +import org.dnd.timeet.agenda.dto.AgendaPatchResponse; import org.dnd.timeet.common.security.CustomUserDetails; import org.dnd.timeet.common.utils.ApiUtils; import org.dnd.timeet.common.utils.ApiUtils.ApiResult; @@ -19,6 +21,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -81,4 +84,15 @@ public ResponseEntity deleteAgenda( return ResponseEntity.noContent().build(); } + + @PatchMapping("/{meeting-id}/agendas/{agenda-id}") + @Operation(summary = "안건 수정", description = "지정된 ID에 해당하는 안건을 수정한다.") + public ResponseEntity> deleteAgenda( + @PathVariable("meeting-id") Long meetingId, + @PathVariable("agenda-id") Long agendaId, + @RequestBody AgendaPatchRequest patchRequest) { + AgendaPatchResponse response = agendaService.patchAgenda(meetingId, agendaId, patchRequest); + + return ResponseEntity.ok(ApiUtils.success(response)); + } } diff --git a/src/main/java/org/dnd/timeet/agenda/domain/Agenda.java b/src/main/java/org/dnd/timeet/agenda/domain/Agenda.java index 2c50b4f..43d2af2 100644 --- a/src/main/java/org/dnd/timeet/agenda/domain/Agenda.java +++ b/src/main/java/org/dnd/timeet/agenda/domain/Agenda.java @@ -158,5 +158,10 @@ public void cancel() { this.delete(); } + public void update(String title, Duration allocatedDuration) { + this.title = title; + this.allocatedDuration = allocatedDuration; + } + } diff --git a/src/main/java/org/dnd/timeet/agenda/domain/AgendaRepository.java b/src/main/java/org/dnd/timeet/agenda/domain/AgendaRepository.java index 8d08a36..9215595 100644 --- a/src/main/java/org/dnd/timeet/agenda/domain/AgendaRepository.java +++ b/src/main/java/org/dnd/timeet/agenda/domain/AgendaRepository.java @@ -9,4 +9,9 @@ public interface AgendaRepository extends JpaRepository { List findByMeetingId(Long meetingId); Optional findByIdAndMeetingId(Long agendaId, Long meetingId); + + boolean existsByMeetingIdAndOrderNum(Long meetingId, Integer orderNum); + + boolean existsByMeetingIdAndOrderNumAndStatus(Long meetingId, Integer orderNum, AgendaStatus status); + } diff --git a/src/main/java/org/dnd/timeet/agenda/dto/AgendaActionResponse.java b/src/main/java/org/dnd/timeet/agenda/dto/AgendaActionResponse.java index 94f195e..62f694a 100644 --- a/src/main/java/org/dnd/timeet/agenda/dto/AgendaActionResponse.java +++ b/src/main/java/org/dnd/timeet/agenda/dto/AgendaActionResponse.java @@ -24,7 +24,6 @@ public class AgendaActionResponse { private String currentDuration; // 현재까지 진행된 시간 private String remainingDuration; // 남은 시간 - private Integer orderNum; private String timestamp; // 수정 시간 public AgendaActionResponse(Agenda agenda, Duration currentDuration, Duration remainingDuration) { @@ -36,7 +35,6 @@ public AgendaActionResponse(Agenda agenda, Duration currentDuration, Duration re this.currentDuration = DurationUtils.formatDuration(currentDuration); this.remainingDuration = DurationUtils.formatDuration(remainingDuration); - this.orderNum = agenda.getOrderNum(); this.timestamp = DateTimeUtils.formatLocalDateTime(LocalDateTime.now()); } diff --git a/src/main/java/org/dnd/timeet/agenda/dto/AgendaCreateRequest.java b/src/main/java/org/dnd/timeet/agenda/dto/AgendaCreateRequest.java index abeddc4..1882702 100644 --- a/src/main/java/org/dnd/timeet/agenda/dto/AgendaCreateRequest.java +++ b/src/main/java/org/dnd/timeet/agenda/dto/AgendaCreateRequest.java @@ -32,17 +32,12 @@ public class AgendaCreateRequest { @Schema(description = "안건 소요 시간", example = "01:20:00") private LocalTime allocatedDuration; - @NotNull(message = "안건 순서는 반드시 입력되어야 합니다") - @Schema(description = "안건 순서", example = "1") - private Integer orderNum; - public Agenda toEntity(Meeting meeting) { return Agenda.builder() .meeting(meeting) .title(this.title) .type(this.type.equals("AGENDA") ? AgendaType.AGENDA : AgendaType.BREAK) .allocatedDuration(DurationUtils.convertLocalTimeToDuration(this.allocatedDuration)) - .orderNum(this.orderNum) .build(); } } diff --git a/src/main/java/org/dnd/timeet/agenda/dto/AgendaPatchRequest.java b/src/main/java/org/dnd/timeet/agenda/dto/AgendaPatchRequest.java new file mode 100644 index 0000000..9568eef --- /dev/null +++ b/src/main/java/org/dnd/timeet/agenda/dto/AgendaPatchRequest.java @@ -0,0 +1,22 @@ +package org.dnd.timeet.agenda.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalTime; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.format.annotation.DateTimeFormat; + +@Schema(description = "안건 수정 요청") +@Getter +@Setter +@NoArgsConstructor +public class AgendaPatchRequest { + + @Schema(description = "안건 제목", example = "브레인스토밍") + private String title; + + @DateTimeFormat(pattern = "HH:mm:ss") + @Schema(description = "안건 소요 시간", example = "01:20:00") + private LocalTime allocatedDuration; +} diff --git a/src/main/java/org/dnd/timeet/agenda/dto/AgendaPatchResponse.java b/src/main/java/org/dnd/timeet/agenda/dto/AgendaPatchResponse.java new file mode 100644 index 0000000..a4863e1 --- /dev/null +++ b/src/main/java/org/dnd/timeet/agenda/dto/AgendaPatchResponse.java @@ -0,0 +1,31 @@ +package org.dnd.timeet.agenda.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.dnd.timeet.agenda.domain.Agenda; +import org.dnd.timeet.common.utils.DurationUtils; + +@Schema(description = "안건 수정 응답") +@Getter +@Setter +public class AgendaPatchResponse { + + @Schema(description = "안건 id", example = "12") + private Long agendaId; + + @Schema(description = "안건 제목", example = "안건 1") + private String title; + + @NotNull(message = "안건 소요 시간은 반드시 입력되어야 합니다") + @Schema(description = "안건 소요 시간", example = "01:20:00") + private String allocatedDuration; + + public AgendaPatchResponse(Agenda agenda) { + this.agendaId = agenda.getId(); + this.title = agenda.getTitle(); + this.allocatedDuration = DurationUtils.formatDuration(agenda.getAllocatedDuration()); + } + +} \ No newline at end of file diff --git a/src/main/java/org/dnd/timeet/common/utils/DurationUtils.java b/src/main/java/org/dnd/timeet/common/utils/DurationUtils.java index 699e891..71de09c 100644 --- a/src/main/java/org/dnd/timeet/common/utils/DurationUtils.java +++ b/src/main/java/org/dnd/timeet/common/utils/DurationUtils.java @@ -12,8 +12,8 @@ public class DurationUtils { * @return Duration 객체 */ public static Duration convertLocalTimeToDuration(LocalTime time) { - int totalMinutes = time.getHour() * 60 + time.getMinute(); - return Duration.ofMinutes(totalMinutes); + int totalSeconds = time.toSecondOfDay(); // 시, 분, 초를 모두 초 단위로 변환 + return Duration.ofSeconds(totalSeconds); // 변환된 초를 바탕으로 Duration 생성 } /** diff --git a/src/main/java/org/dnd/timeet/meeting/application/MeetingAsyncService.java b/src/main/java/org/dnd/timeet/meeting/application/MeetingAsyncService.java index 67bd11b..ae06eb3 100644 --- a/src/main/java/org/dnd/timeet/meeting/application/MeetingAsyncService.java +++ b/src/main/java/org/dnd/timeet/meeting/application/MeetingAsyncService.java @@ -1,15 +1,10 @@ package org.dnd.timeet.meeting.application; -import java.util.Collections; import lombok.RequiredArgsConstructor; -import org.dnd.timeet.common.exception.NotFoundError; -import org.dnd.timeet.meeting.domain.Meeting; import org.dnd.timeet.meeting.domain.MeetingRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -18,29 +13,18 @@ public class MeetingAsyncService { private final MeetingRepository meetingRepository; private final Logger logger = LoggerFactory.getLogger(MeetingAsyncService.class); - @Transactional - @Async // 비동기 작업 실행시 발생하는 에러 처리 - public void startScheduledMeeting(Long meetingId) { - try { - Meeting meeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new NotFoundError(NotFoundError.ErrorCode.RESOURCE_NOT_FOUND, - Collections.singletonMap("MeetingId", "Meeting not found"))); - - meeting.startMeeting(); - meetingRepository.save(meeting); - } catch (Exception e) { - logger.error("Error starting scheduled meeting", e); - } - } - - @Transactional - @Async // 비동기 작업 실행시 발생하는 에러 처리 - public void endScheduledMeeting(Meeting meeting) { - try { - meeting.endMeeting(); - meetingRepository.save(meeting); - } catch (Exception e) { - logger.error("Error starting scheduled meeting", e); - } - } +// @Transactional +// @Async // 비동기 작업 실행시 발생하는 에러 처리 +// public void startScheduledMeeting(Long meetingId) { +// try { +// Meeting meeting = meetingRepository.findById(meetingId) +// .orElseThrow(() -> new NotFoundError(NotFoundError.ErrorCode.RESOURCE_NOT_FOUND, +// Collections.singletonMap("MeetingId", "Meeting not found"))); +// +// meeting.startMeeting(); +// meetingRepository.save(meeting); +// } catch (Exception e) { +// logger.error("Error starting scheduled meeting", e); +// } +// } } \ No newline at end of file diff --git a/src/main/java/org/dnd/timeet/meeting/application/MeetingScheduler.java b/src/main/java/org/dnd/timeet/meeting/application/MeetingScheduler.java index 3e3b4aa..4af799f 100644 --- a/src/main/java/org/dnd/timeet/meeting/application/MeetingScheduler.java +++ b/src/main/java/org/dnd/timeet/meeting/application/MeetingScheduler.java @@ -1,18 +1,9 @@ package org.dnd.timeet.meeting.application; -import java.time.Duration; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import java.util.Collections; -import java.util.List; import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; -import org.dnd.timeet.common.exception.BadRequestError; -import org.dnd.timeet.meeting.domain.Meeting; import org.dnd.timeet.meeting.domain.MeetingRepository; import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @EnableScheduling @@ -24,31 +15,16 @@ public class MeetingScheduler { private final ScheduledExecutorService scheduledExecutorService; private final MeetingRepository meetingRepository; - public void scheduleMeetingStart(Long meetingId, LocalDateTime startTime) { - long delay = ChronoUnit.MILLIS.between(LocalDateTime.now(), startTime); - if (delay < 0) { - throw new BadRequestError(BadRequestError.ErrorCode.VALIDATION_FAILED, - Collections.singletonMap("startTime", "startTime is past")); - } - - // 스케줄러 생성 - scheduledExecutorService.schedule(() -> - meetingAsyncService.startScheduledMeeting(meetingId), delay, TimeUnit.MILLISECONDS); - } - - @Scheduled(fixedRate = 60000) // 60000ms = 1분 - public void scheduleMeetingEnd() { - List meetingsByStatusInProgress = meetingRepository.findMeetingsByStatusInProgress(); - - LocalDateTime now = LocalDateTime.now(); - - meetingsByStatusInProgress.forEach(meeting -> { - // 남은 시간이 0이거나 음수인 경우 회의를 종료 - Duration remainingTime = meeting.calculateRemainingTime(); - if (remainingTime.isZero() || remainingTime.isNegative()) { - meetingAsyncService.endScheduledMeeting(meeting); - } - }); - } +// public void scheduleMeetingStart(Long meetingId, LocalDateTime startTime) { +// long delay = ChronoUnit.MILLIS.between(LocalDateTime.now(), startTime); +// if (delay < 0) { +// throw new BadRequestError(BadRequestError.ErrorCode.VALIDATION_FAILED, +// Collections.singletonMap("startTime", "startTime is past")); +// } +// +// // 스케줄러 생성 +// scheduledExecutorService.schedule(() -> +// meetingAsyncService.startScheduledMeeting(meetingId), delay, TimeUnit.MILLISECONDS); +// } } diff --git a/src/main/java/org/dnd/timeet/meeting/application/MeetingService.java b/src/main/java/org/dnd/timeet/meeting/application/MeetingService.java index 5dac305..a240941 100644 --- a/src/main/java/org/dnd/timeet/meeting/application/MeetingService.java +++ b/src/main/java/org/dnd/timeet/meeting/application/MeetingService.java @@ -12,16 +12,18 @@ import org.dnd.timeet.agenda.domain.AgendaStatus; import org.dnd.timeet.agenda.domain.AgendaType; import org.dnd.timeet.agenda.dto.AgendaReportInfoResponse; -import org.dnd.timeet.common.exception.BadRequestError; import org.dnd.timeet.common.exception.ForbiddenError; import org.dnd.timeet.common.exception.NotFoundError; import org.dnd.timeet.common.exception.NotFoundError.ErrorCode; import org.dnd.timeet.meeting.domain.Meeting; import org.dnd.timeet.meeting.domain.MeetingRepository; import org.dnd.timeet.meeting.dto.MeetingCreateRequest; +import org.dnd.timeet.meeting.dto.MeetingMemberInfoResponse; +import org.dnd.timeet.meeting.dto.MeetingMemberInfoResponse.MeetingMemberDetailResponse; import org.dnd.timeet.meeting.dto.MeetingRemainingTimeResponse; import org.dnd.timeet.meeting.dto.MeetingReportInfoResponse; import org.dnd.timeet.member.domain.Member; +import org.dnd.timeet.member.domain.MemberRepository; import org.dnd.timeet.participant.domain.Participant; import org.dnd.timeet.participant.domain.ParticipantRepository; import org.springframework.stereotype.Service; @@ -35,7 +37,7 @@ public class MeetingService { private final MeetingRepository meetingRepository; private final ParticipantRepository participantRepository; private final AgendaRepository agendaRepository; - private final MeetingScheduler meetingScheduler; + private final MemberRepository memberRepository; public Meeting createMeeting(MeetingCreateRequest createDto, Member member) { @@ -45,9 +47,6 @@ public Meeting createMeeting(MeetingCreateRequest createDto, Member member) { Participant participant = new Participant(meeting, member); participantRepository.save(participant); - // 스케줄러를 통해 회의 시작 시간에 회의 시작 - meetingScheduler.scheduleMeetingStart(meeting.getId(), meeting.getStartTime()); - return meeting; } @@ -55,6 +54,11 @@ public void endMeeting(Long meetingId, Long memberId) { Meeting meeting = meetingRepository.findById(meetingId) .orElseThrow(() -> new NotFoundError(NotFoundError.ErrorCode.RESOURCE_NOT_FOUND, Collections.singletonMap("MeetingId", "Meeting not found"))); + // 회의의 방장이 존재하는지 확인 + if (meeting.getHostMember() == null) { + throw new NotFoundError(NotFoundError.ErrorCode.RESOURCE_NOT_FOUND, + Collections.singletonMap("HostMemberId", "Host member not found")); + } // 회의의 방장인지 확인 if (!Objects.equals(meeting.getHostMember().getId(), memberId)) { throw new ForbiddenError(ForbiddenError.ErrorCode.ROLE_BASED_ACCESS_ERROR, @@ -79,23 +83,7 @@ public Meeting addParticipantToMeeting(Long meetingId, Member member) { .orElseThrow(() -> new NotFoundError(NotFoundError.ErrorCode.RESOURCE_NOT_FOUND, Collections.singletonMap("MeetingId", "Meeting not found"))); - // 멤버가 이미 회의에 참가하고 있는지 확인 - boolean alreadyParticipating = meeting.getParticipants().stream() - .anyMatch(participant -> participant.getMember().equals(member)); - - if (alreadyParticipating) { - // 에러 메세지 발생 - throw new BadRequestError(BadRequestError.ErrorCode.DUPLICATE_RESOURCE, - Collections.singletonMap("Member", "Member already participating in the meeting")); - } - - // Participant 인스턴스 생성 및 저장 - Participant participant = new Participant(meeting, member); - participantRepository.save(participant); - - // 양방향 연관관계 설정 - meeting.getParticipants().add(participant); - member.getParticipations().add(participant); + participantRepository.save(meeting.addParticipant(member)); return meeting; } @@ -147,14 +135,35 @@ public void cancelMeeting(Long meetingId) { } @Transactional(readOnly = true) - public List getMeetingMembers(Long meetingId) { + public MeetingMemberInfoResponse getMeetingMembers(Long meetingId) { Meeting meeting = meetingRepository.findByIdWithParticipantsAndMembers(meetingId) .orElseThrow(() -> new NotFoundError(NotFoundError.ErrorCode.RESOURCE_NOT_FOUND, Collections.singletonMap("MeetingId", "Meeting not found"))); - return meeting.getParticipants().stream() + // 회의에 참가자가 없는 경우 빈 리스트 반환 + if (meeting.getParticipants().isEmpty()) { + return new MeetingMemberInfoResponse(null, Collections.emptyList()); + } + + MeetingMemberDetailResponse hostResponse; + // 방장 정보 추출 + if (meeting.getHostMember() == null) { + hostResponse = new MeetingMemberDetailResponse(null); + } else { + Member hostMember = memberRepository.findById(meeting.getHostMember().getId()) + .orElseThrow(() -> new NotFoundError(ErrorCode.RESOURCE_NOT_FOUND, + Collections.singletonMap("HostMemberId", "Host member not found"))); + hostResponse = new MeetingMemberDetailResponse(hostMember); + } + + // 참가자 목록을 Member 객체의 리스트로 변환하여 반환 + List memberList = meeting.getParticipants().stream() .map(Participant::getMember) - .collect(Collectors.toList()); + .filter(member -> !member.equals(meeting.getHostMember())) // 방장 제외 + .map(MeetingMemberDetailResponse::new) + .toList(); + + return new MeetingMemberInfoResponse(hostResponse, memberList); } @Transactional(readOnly = true) @@ -166,4 +175,28 @@ public MeetingRemainingTimeResponse getRemainingTime(Long meetingId) { return MeetingRemainingTimeResponse.from(meeting); } + public void leaveMeeting(Long meetingId, Long memberId) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new NotFoundError(ErrorCode.RESOURCE_NOT_FOUND, + Collections.singletonMap("MeetingId", "Meeting not found"))); + + boolean memberExists = memberRepository.existsById(memberId); + if (!memberExists) { + throw new NotFoundError(ErrorCode.RESOURCE_NOT_FOUND, + Collections.singletonMap("MemberId", "Member not found")); + } + + Participant participant = participantRepository.findByMeetingIdAndMemberId(meetingId, memberId) + .orElseThrow(() -> new NotFoundError(NotFoundError.ErrorCode.RESOURCE_NOT_FOUND, + Collections.singletonMap("ParticipantId", "Participant not found"))); + + // 방장이 나가는 경우 새로운 방장 지정 + if (meeting.getHostMember() == null || meeting.getHostMember().getId().equals(memberId)) { + meeting.assignNewHostRandomly(); + meetingRepository.save(meeting); + } + + participant.removeParticipant(); + participantRepository.save(participant); + } } diff --git a/src/main/java/org/dnd/timeet/meeting/controller/MeetingController.java b/src/main/java/org/dnd/timeet/meeting/controller/MeetingController.java index d899a70..2947eeb 100644 --- a/src/main/java/org/dnd/timeet/meeting/controller/MeetingController.java +++ b/src/main/java/org/dnd/timeet/meeting/controller/MeetingController.java @@ -3,8 +3,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import java.util.List; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.dnd.timeet.common.security.CustomUserDetails; import org.dnd.timeet.common.security.annotation.ReqUser; @@ -15,12 +13,11 @@ import org.dnd.timeet.meeting.dto.MeetingCreateRequest; import org.dnd.timeet.meeting.dto.MeetingCreateResponse; import org.dnd.timeet.meeting.dto.MeetingInfoResponse; +import org.dnd.timeet.meeting.dto.MeetingMemberInfoResponse; import org.dnd.timeet.meeting.dto.MeetingRemainingTimeResponse; import org.dnd.timeet.meeting.dto.MeetingReportInfoResponse; import org.dnd.timeet.meeting.dto.MeetingReportResponse; import org.dnd.timeet.member.domain.Member; -import org.dnd.timeet.member.dto.MemberInfoListResponse; -import org.dnd.timeet.member.dto.MemberInfoResponse; import org.springframework.http.ResponseEntity; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -118,16 +115,18 @@ public ResponseEntity deleteMeeting(@PathVariable("meeting-id") Long meetingId) @GetMapping("/{meeting-id}/users") @Operation(summary = "회의 참가자 조회", description = "회의에 참가한 사용자를 조회한다.") - public ResponseEntity> getMeetingMembers( + public ResponseEntity> getMeetingMembers( @PathVariable("meeting-id") Long meetingId) { - List memberInfoList = meetingService.getMeetingMembers(meetingId) - .stream() - .map(MemberInfoResponse::from) - .collect(Collectors.toList()); + MeetingMemberInfoResponse response = meetingService.getMeetingMembers(meetingId); - MemberInfoListResponse memberInfoListResponse = new MemberInfoListResponse(memberInfoList); - - return ResponseEntity.ok(ApiUtils.success(memberInfoListResponse)); + return ResponseEntity.ok(ApiUtils.success(response)); } + @DeleteMapping("/{meeting-id}/leave") + @Operation(summary = "회의실 나가기", description = "지정된 id에 해당하는 회의에서 나간다.") + public ResponseEntity leaveMeeting(@PathVariable("meeting-id") Long meetingId, @ReqUser Member member) { + meetingService.leaveMeeting(meetingId, member.getId()); + + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/org/dnd/timeet/meeting/domain/Meeting.java b/src/main/java/org/dnd/timeet/meeting/domain/Meeting.java index b28833e..5344b0d 100644 --- a/src/main/java/org/dnd/timeet/meeting/domain/Meeting.java +++ b/src/main/java/org/dnd/timeet/meeting/domain/Meeting.java @@ -18,11 +18,11 @@ import java.util.List; import java.util.Random; import java.util.Set; -import java.util.stream.Collectors; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.dnd.timeet.agenda.domain.Agenda; import org.dnd.timeet.common.domain.AuditableEntity; import org.dnd.timeet.common.exception.BadRequestError; import org.dnd.timeet.common.exception.BadRequestError.ErrorCode; @@ -88,9 +88,12 @@ public Meeting(Member hostMember, String title, LocalDateTime startTime, Duratio this.imgNum = imgNum; } - - public void startMeeting() { - this.status = MeetingStatus.INPROGRESS; + public void updateStartTimeOnFirstAgendaStart(Agenda agenda) { + if (agenda.getOrderNum() == 1) { + // 회의 시작 + this.startTime = LocalDateTime.now(); + this.status = MeetingStatus.INPROGRESS; + } } // 회의 종료 버튼 누르거나 소요 시간이 끝날 경우 @@ -140,8 +143,14 @@ public void assignNewHostRandomly() { List participantsList = this.participants.stream() .map(Participant::getMember) - .collect(Collectors.toList()); + .filter(member -> !member.equals(this.hostMember)) // 현재 방장 제외 + .toList(); + // 회의에 방장만 존재할 경우 + if (participantsList.isEmpty()) { + this.hostMember = null; + return; + } // 랜덤 객체를 사용하여 참가자 목록에서 랜덤하게 하나를 선택 Random random = new Random(); int index = random.nextInt(participantsList.size()); @@ -191,5 +200,25 @@ public Duration calculateRemainingTime() { } } + public boolean isMemberParticipating(Member member) { + return this.participants.stream() + .anyMatch(participant -> participant.getMember().equals(member)); + } + + public Participant addParticipant(Member member) { + // 이미 참가중인 회원이라면 예외 발생 + if (this.isMemberParticipating(member)) { + throw new BadRequestError(BadRequestError.ErrorCode.DUPLICATE_RESOURCE, + Collections.singletonMap("Member", "Member already participating in the meeting")); + } + + // 회의에 아무도 없다면 방장으로 지정 + if (this.participants.isEmpty()) { + this.assignHostMember(member); + } + + return new Participant(this, member); + } + } diff --git a/src/main/java/org/dnd/timeet/meeting/domain/MeetingRepository.java b/src/main/java/org/dnd/timeet/meeting/domain/MeetingRepository.java index cd9512f..d523685 100644 --- a/src/main/java/org/dnd/timeet/meeting/domain/MeetingRepository.java +++ b/src/main/java/org/dnd/timeet/meeting/domain/MeetingRepository.java @@ -8,7 +8,7 @@ public interface MeetingRepository extends JpaRepository { - @Query("select m from Meeting m join fetch m.participants p join fetch p.member where m.id = :meetingId") + @Query("select m from Meeting m left join fetch m.participants p left join fetch p.member where m.id = :meetingId") Optional findByIdWithParticipantsAndMembers(@Param("meetingId") Long meetingId); @Query("SELECT m FROM Meeting m WHERE m.status = 'INPROGRESS'") diff --git a/src/main/java/org/dnd/timeet/meeting/dto/MeetingInfoResponse.java b/src/main/java/org/dnd/timeet/meeting/dto/MeetingInfoResponse.java index b692725..7307a34 100644 --- a/src/main/java/org/dnd/timeet/meeting/dto/MeetingInfoResponse.java +++ b/src/main/java/org/dnd/timeet/meeting/dto/MeetingInfoResponse.java @@ -5,6 +5,7 @@ import lombok.Builder; import lombok.Getter; import lombok.Setter; +import org.dnd.timeet.common.utils.DateTimeUtils; import org.dnd.timeet.common.utils.DurationUtils; import org.dnd.timeet.meeting.domain.Meeting; @@ -28,7 +29,7 @@ public class MeetingInfoResponse { @Schema(description = "회의 방장 멤버 ID", example = "13") private Long hostMemberId; - @Schema(description = "회의 시작 일자", example = "2024-01-11T13:20") + @Schema(description = "회의 시작 일자", example = "2024-01-11T13:20:00") private String startTime; @Schema(description = "예상 소요시간", example = "03:00:00") @@ -66,8 +67,8 @@ public static MeetingInfoResponse from(Meeting meeting) { // 매개변수로부 .title(meeting.getTitle()) .description(meeting.getDescription()) .meetingStatus(meeting.getStatus().name()) - .hostMemberId(meeting.getHostMember().getId()) - .startTime(meeting.getStartTime().toString()) + .hostMemberId(meeting.getHostMember() == null ? null : meeting.getHostMember().getId()) + .startTime(DateTimeUtils.formatLocalDateTime(meeting.getStartTime())) .totalEstimatedDuration(meeting.getTotalEstimatedDuration()) .remainingTime(meeting.calculateRemainingTime()) .actualTotalDuration(meeting.calculateCurrentDuration()) diff --git a/src/main/java/org/dnd/timeet/meeting/dto/MeetingMemberInfoResponse.java b/src/main/java/org/dnd/timeet/meeting/dto/MeetingMemberInfoResponse.java new file mode 100644 index 0000000..85c9611 --- /dev/null +++ b/src/main/java/org/dnd/timeet/meeting/dto/MeetingMemberInfoResponse.java @@ -0,0 +1,40 @@ +package org.dnd.timeet.meeting.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Getter; +import lombok.Setter; +import org.dnd.timeet.member.domain.Member; + +@Schema(description = "회의 멤버 정보 응답") +@Getter +@Setter +public class MeetingMemberInfoResponse { + + MeetingMemberDetailResponse hostMember; + List members; + + public MeetingMemberInfoResponse(MeetingMemberDetailResponse hostMember, + List members) { + this.hostMember = hostMember; + this.members = members; + } + + @Getter + @Setter + public static class MeetingMemberDetailResponse { + + @Schema(description = "사용자 id", nullable = false, example = "12") + private Long id; + @Schema(description = "사용자 이름", nullable = false, example = "green12") + private String nickname; + + + public MeetingMemberDetailResponse(Member member) { + this.id = member == null ? null : member.getId(); + this.nickname = member == null ? null : member.getName(); + } + } +} + + diff --git a/src/main/java/org/dnd/timeet/participant/domain/Participant.java b/src/main/java/org/dnd/timeet/participant/domain/Participant.java index 4783e79..2209bc2 100644 --- a/src/main/java/org/dnd/timeet/participant/domain/Participant.java +++ b/src/main/java/org/dnd/timeet/participant/domain/Participant.java @@ -4,25 +4,15 @@ import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; import jakarta.persistence.Table; -import java.time.Duration; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.dnd.timeet.common.domain.AuditableEntity; -import org.dnd.timeet.common.exception.BadRequestError; import org.dnd.timeet.meeting.domain.Meeting; import org.dnd.timeet.member.domain.Member; import org.hibernate.annotations.Where; @@ -52,6 +42,17 @@ public Participant(Meeting meeting, Member member) { member.getParticipations().add(this); } + public void removeParticipant() { + if (this.meeting != null) { + this.meeting.getParticipants().remove(this); + this.meeting = null; + } + if (this.member != null) { + this.member.getParticipations().remove(this); + this.member = null; + } + this.delete(); + } } diff --git a/src/main/java/org/dnd/timeet/participant/domain/ParticipantRepository.java b/src/main/java/org/dnd/timeet/participant/domain/ParticipantRepository.java index dce4b93..36a78c4 100644 --- a/src/main/java/org/dnd/timeet/participant/domain/ParticipantRepository.java +++ b/src/main/java/org/dnd/timeet/participant/domain/ParticipantRepository.java @@ -1,12 +1,11 @@ package org.dnd.timeet.participant.domain; import java.util.Optional; -import org.dnd.timeet.participant.domain.Participant; import org.springframework.data.jpa.repository.JpaRepository; public interface ParticipantRepository extends JpaRepository { Optional findByMeetingIdAndMemberId(Long meetingId, Long memberId); -// Optional findByUserId(Long id); + boolean existsByMeetingIdAndMemberId(Long meetingId, Long memberId); }