diff --git a/src/main/java/grep/neogulcoder/domain/alram/controller/AlarmController.java b/src/main/java/grep/neogulcoder/domain/alram/controller/AlarmController.java new file mode 100644 index 00000000..e3ee0ca7 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/alram/controller/AlarmController.java @@ -0,0 +1,48 @@ +package grep.neogulcoder.domain.alram.controller; + +import grep.neogulcoder.domain.alram.controller.dto.response.AlarmResponse; +import grep.neogulcoder.domain.alram.service.AlarmService; +import grep.neogulcoder.global.auth.Principal; +import grep.neogulcoder.global.response.ApiResponse; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/alarm") +public class AlarmController implements AlarmSpecification { + + private final AlarmService alarmService; + + @GetMapping("/my") + public ApiResponse> getAllAlarm( + @AuthenticationPrincipal Principal userDetails) { + return ApiResponse.success(alarmService.getAllAlarms(userDetails.getUserId())); + } + + @PostMapping("/my/check/all") + public ApiResponse checkAlarm(@AuthenticationPrincipal Principal userDetails) { + alarmService.checkAllAlarm(userDetails.getUserId()); + return ApiResponse.noContent(); + } + + @PostMapping("/choose/{alarmId}/response") + public ApiResponse respondToInvite(@AuthenticationPrincipal Principal principal, + @PathVariable Long alarmId, + boolean accepted) { + if (accepted) { + alarmService.acceptInvite(principal.getUserId(), alarmId); + } else { + alarmService.rejectInvite(principal.getUserId()); + } + + return ApiResponse.success(accepted ? "스터디 초대를 수락했습니다." : "스터디 초대를 거절했습니다."); + } + +} diff --git a/src/main/java/grep/neogulcoder/domain/alram/controller/AlarmSpecification.java b/src/main/java/grep/neogulcoder/domain/alram/controller/AlarmSpecification.java new file mode 100644 index 00000000..a857f960 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/alram/controller/AlarmSpecification.java @@ -0,0 +1,19 @@ +package grep.neogulcoder.domain.alram.controller; + +import grep.neogulcoder.domain.alram.controller.dto.response.AlarmResponse; +import grep.neogulcoder.global.auth.Principal; +import grep.neogulcoder.global.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +@Tag(name = "Alarm", description = "알림 관련 API 명세") +public interface AlarmSpecification { + + @Operation(summary = "내 알림 목록 조회", description = "로그인한 사용자의 알림 목록을 조회합니다.") + ApiResponse> getAllAlarm(@AuthenticationPrincipal Principal userDetails); + + @Operation(summary = "내 알림 전체 읽음 처리", description = "로그인한 사용자의 모든 알림을 읽음 처리합니다.") + ApiResponse checkAlarm(@AuthenticationPrincipal Principal userDetails); +} diff --git a/src/main/java/grep/neogulcoder/domain/alram/controller/dto/response/AlarmResponse.java b/src/main/java/grep/neogulcoder/domain/alram/controller/dto/response/AlarmResponse.java new file mode 100644 index 00000000..98e96bf7 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/alram/controller/dto/response/AlarmResponse.java @@ -0,0 +1,46 @@ +package grep.neogulcoder.domain.alram.controller.dto.response; + +import grep.neogulcoder.domain.alram.entity.Alarm; +import grep.neogulcoder.domain.alram.type.AlarmType; +import grep.neogulcoder.domain.alram.type.DomainType; +import lombok.Builder; +import lombok.Data; + +@Data +public class AlarmResponse { + + private Long id; + + private Long receiverUserId; + + private AlarmType alarmType; + + private DomainType domainType; + + private Long domainId; + + private String message; + + public static AlarmResponse toResponse(Long id, Long receiverUserId, AlarmType alarmType, DomainType domainType, + Long domainId, String message) { + return AlarmResponse.builder() + .id(id) + .receiverUserId(receiverUserId) + .alarmType(alarmType) + .domainType(domainType) + .domainId(domainId) + .message(message) + .build(); + } + + @Builder + private AlarmResponse(Long id, Long receiverUserId, AlarmType alarmType, DomainType domainType, + Long domainId, String message) { + this.id = id; + this.receiverUserId = receiverUserId; + this.alarmType = alarmType; + this.domainType = domainType; + this.domainId = domainId; + this.message = message; + } +} diff --git a/src/main/java/grep/neogulcoder/domain/alram/entity/Alarm.java b/src/main/java/grep/neogulcoder/domain/alram/entity/Alarm.java new file mode 100644 index 00000000..0cacf575 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/alram/entity/Alarm.java @@ -0,0 +1,67 @@ +package grep.neogulcoder.domain.alram.entity; + +import grep.neogulcoder.domain.alram.type.AlarmType; +import grep.neogulcoder.domain.alram.type.DomainType; +import grep.neogulcoder.global.entity.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; + +@Entity +@Getter +@Schema(description = "알림 정보") +public class Alarm extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long receiverUserId; + + @Enumerated(EnumType.STRING) + private AlarmType alarmType; + + @Enumerated(EnumType.STRING) + private DomainType domainType; + + private Long domainId; + + private String message; + + private boolean checked = false; + + public void checkAlarm() { + this.checked = true; + } + + public static Alarm init(AlarmType alarmType, Long receiverUserId , DomainType domainType, Long domainId, String message) { + return Alarm.builder() + .alarmType(alarmType) + .receiverUserId(receiverUserId) + .domainType(domainType) + .domainId(domainId) + .message(message) + .build(); + } + + @Builder + private Alarm(Long id, Long receiverUserId, AlarmType alarmType, DomainType domainType, + Long domainId, String message) { + this.id = id; + this.receiverUserId = receiverUserId; + this.alarmType = alarmType; + this.domainType = domainType; + this.domainId = domainId; + this.message = message; + } + + protected Alarm() { + } + +} diff --git a/src/main/java/grep/neogulcoder/domain/alram/exception/code/AlarmErrorCode.java b/src/main/java/grep/neogulcoder/domain/alram/exception/code/AlarmErrorCode.java new file mode 100644 index 00000000..50262a07 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/alram/exception/code/AlarmErrorCode.java @@ -0,0 +1,22 @@ +package grep.neogulcoder.domain.alram.exception.code; + +import grep.neogulcoder.global.response.code.ErrorCode; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum AlarmErrorCode implements ErrorCode { + + ALARM_NOT_FOUND("A001",HttpStatus.NOT_FOUND,"알람을 찾을 수 없습니다."); + + + private final String code; + private final HttpStatus status; + private final String message; + + AlarmErrorCode(String code, HttpStatus status, String message) { + this.code = code; + this.status = status; + this.message = message; + } +} diff --git a/src/main/java/grep/neogulcoder/domain/alram/repository/AlarmRepository.java b/src/main/java/grep/neogulcoder/domain/alram/repository/AlarmRepository.java new file mode 100644 index 00000000..7a03e15b --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/alram/repository/AlarmRepository.java @@ -0,0 +1,10 @@ +package grep.neogulcoder.domain.alram.repository; + +import grep.neogulcoder.domain.alram.entity.Alarm; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AlarmRepository extends JpaRepository { + List findAllByReceiverUserIdAndCheckedFalse(Long receiverUserId); + +} diff --git a/src/main/java/grep/neogulcoder/domain/alram/service/AlarmService.java b/src/main/java/grep/neogulcoder/domain/alram/service/AlarmService.java new file mode 100644 index 00000000..1fdcf6c2 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/alram/service/AlarmService.java @@ -0,0 +1,105 @@ +package grep.neogulcoder.domain.alram.service; + +import grep.neogulcoder.domain.alram.controller.dto.response.AlarmResponse; +import grep.neogulcoder.domain.alram.entity.Alarm; +import grep.neogulcoder.domain.alram.exception.code.AlarmErrorCode; +import grep.neogulcoder.domain.alram.repository.AlarmRepository; +import grep.neogulcoder.domain.alram.type.AlarmType; +import grep.neogulcoder.domain.alram.type.DomainType; +import grep.neogulcoder.domain.study.Study; +import grep.neogulcoder.domain.study.StudyMember; +import grep.neogulcoder.domain.study.event.StudyInviteEvent; +import grep.neogulcoder.domain.study.repository.StudyMemberQueryRepository; +import grep.neogulcoder.domain.study.repository.StudyRepository; +import grep.neogulcoder.global.exception.business.BusinessException; +import grep.neogulcoder.global.exception.business.NotFoundException; +import grep.neogulcoder.global.provider.finder.MessageFinder; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.STUDY_NOT_FOUND; +import static grep.neogulcoder.domain.studyapplication.exception.code.ApplicationErrorCode.APPLICATION_PARTICIPANT_LIMIT_EXCEEDED; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AlarmService { + + private final AlarmRepository alarmRepository; + private final MessageFinder messageFinder; + private final StudyRepository studyRepository; + private final StudyMemberQueryRepository studyMemberQueryRepository; + + @Transactional + public void saveAlarm(Long receiverId, AlarmType alarmType, DomainType domainType, Long domainId) { + String message = messageFinder.findMessage(alarmType, domainType, domainId); + alarmRepository.save(Alarm.init(alarmType, receiverId, domainType, domainId, message)); + } + + public List getAllAlarms(Long receiverUserId) { + return alarmRepository.findAllByReceiverUserIdAndCheckedFalse(receiverUserId).stream() + .map(alarm -> AlarmResponse.toResponse( + alarm.getId(), + alarm.getReceiverUserId(), + alarm.getAlarmType(), + alarm.getDomainType(), + alarm.getDomainId(), + alarm.getMessage())) + .toList(); + } + + @Transactional + public void checkAllAlarm(Long receiverUserId) { + List alarms = alarmRepository.findAllByReceiverUserIdAndCheckedFalse(receiverUserId); + alarms.stream() + .filter(alarm -> alarm.getAlarmType() != AlarmType.INVITE) + .forEach(Alarm::checkAlarm); + } + + @EventListener + public void handleStudyInviteEvent(StudyInviteEvent event) { + saveAlarm( + event.targetUserId(), + AlarmType.INVITE, + DomainType.STUDY, + event.studyId() + ); + } + + @Transactional + public void acceptInvite(Long targetUserId, Long alarmId) { + + validateParticipantStudyLimit(targetUserId); + + Alarm alarm = findValidAlarm(alarmId); + Long studyId = alarm.getDomainId(); + Study study = findValidStudy(studyId); + StudyMember.createMember(study,targetUserId); + alarm.checkAlarm(); + } + + @Transactional + public void rejectInvite(Long alarmId) { + Alarm alarm = findValidAlarm(alarmId); + alarm.checkAlarm(); + } + + private Alarm findValidAlarm(Long alarmId) { + return alarmRepository.findById(alarmId).orElseThrow(() -> new NotFoundException(AlarmErrorCode.ALARM_NOT_FOUND)); + } + + private Study findValidStudy(Long studyId) { + return studyRepository.findById(studyId) + .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); + } + + private void validateParticipantStudyLimit(Long userId) { + int count = studyMemberQueryRepository.countActiveUnfinishedStudies(userId); + if (count >= 10) { + throw new BusinessException(APPLICATION_PARTICIPANT_LIMIT_EXCEEDED); + } + } +} diff --git a/src/main/java/grep/neogulcoder/domain/alram/type/AlarmType.java b/src/main/java/grep/neogulcoder/domain/alram/type/AlarmType.java new file mode 100644 index 00000000..0ebce0be --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/alram/type/AlarmType.java @@ -0,0 +1,7 @@ +package grep.neogulcoder.domain.alram.type; + +public enum AlarmType { + + INVITE + +} diff --git a/src/main/java/grep/neogulcoder/domain/alram/type/DomainType.java b/src/main/java/grep/neogulcoder/domain/alram/type/DomainType.java new file mode 100644 index 00000000..81948e41 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/alram/type/DomainType.java @@ -0,0 +1,5 @@ +package grep.neogulcoder.domain.alram.type; + +public enum DomainType { + STUDY +} diff --git a/src/main/java/grep/neogulcoder/domain/buddy/entity/BuddyEnergy.java b/src/main/java/grep/neogulcoder/domain/buddy/entity/BuddyEnergy.java index 19b06268..984417f6 100644 --- a/src/main/java/grep/neogulcoder/domain/buddy/entity/BuddyEnergy.java +++ b/src/main/java/grep/neogulcoder/domain/buddy/entity/BuddyEnergy.java @@ -48,4 +48,11 @@ public void updateEnergy(ReviewType reviewType) { this.level -= 1; } } + + public BuddyEnergyReason findReasonFrom(ReviewType reviewType) { + if(reviewType.isPositive()){ + return BuddyEnergyReason.POSITIVE_REVIEW; + } + return BuddyEnergyReason.NEGATIVE_REVIEW; + } } diff --git a/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatRestController.java b/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatRestController.java index 281c51df..99b02939 100644 --- a/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatRestController.java +++ b/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatRestController.java @@ -16,15 +16,15 @@ public class GroupChatRestController implements GroupChatRestSpecification { // 과거 채팅 메시지 페이징 조회 (무한 스크롤용) @Override - @GetMapping("/room/{roomId}/messages") + @GetMapping("/study/{studyId}/messages") public ApiResponse> getMessages( - @PathVariable("roomId") Long roomId, + @PathVariable("studyId") Long studyId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { // 서비스에서 페이징된 메시지 조회 PageResponse pageResponse = - groupChatService.getMessages(roomId, page, size); + groupChatService.getMessages(studyId, page, size); return ApiResponse.success(pageResponse); } diff --git a/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatRestSpecification.java b/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatRestSpecification.java index c896a77b..eddcb745 100644 --- a/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatRestSpecification.java +++ b/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatRestSpecification.java @@ -16,20 +16,20 @@ public interface GroupChatRestSpecification { summary = "채팅 메시지 페이징 조회", description = """ 이 API는 **채팅방의 과거 메시지**를 페이지 단위로 가져오는 용도입니다. - WebSocket의 실시간 수신(`/sub/chat/room/{roomId}`)과는 별개로, + WebSocket의 실시간 수신(`/sub/chat/study/{studyId}`)과는 별개로, 채팅방에 입장할 때 이전 대화 기록을 불러오는 데 사용됩니다. --- **프론트엔드 연동 흐름 (권장 방식)**: - 1. **채팅방 입장 시** → `GET /api/chat/room/{roomId}/messages?page=0&size=20` 호출해 최신 메시지 20개 로드 + 1. **채팅방 입장 시** → `GET /api/chat/study/{studyId}/messages?page=0&size=20` 호출해 최신 메시지 20개 로드 2. **스크롤 위로 올릴 때** → `page=1`, `page=2` ... 순차적으로 과거 메시지를 추가 로딩 (무한 스크롤) - 3. **동시에** → WebSocket(`wss://wibby.cedartodo.uk/ws-stomp`) 연결 후 `/sub/chat/room/{roomId}`를 **구독**해 실시간 메시지 수신 + 3. **동시에** → WebSocket(`wss://wibby.cedartodo.uk/ws-stomp`) 연결 후 `/sub/chat/study/{studyId}`를 **구독**해 실시간 메시지 수신 --- **파라미터 설명**: - - `roomId`: 채팅방 ID + - `studyId`: 스터디 ID - `page`: 페이지 번호 (0부터 시작, 0 = 최신 메시지 20개) - `size`: 한 페이지당 메시지 수 (기본값 20) - 메시지는 **오래된 순(오름차순)**으로 반환됩니다. @@ -45,7 +45,7 @@ public interface GroupChatRestSpecification { **예시 요청 URL**: ``` - /api/chat/room/1/messages?page=0&size=20 + /api/chat/study/1/messages?page=0&size=20 ``` **예시 응답**: @@ -56,7 +56,7 @@ public interface GroupChatRestSpecification { "content": [ { "id": 101, - "roomId": 1, + "studyId": 1, "senderId": 10, "senderNickname": "유강현", "profileImageUrl": "https://example.com/profile.jpg", @@ -74,8 +74,8 @@ public interface GroupChatRestSpecification { ) ApiResponse> getMessages( - @Parameter(description = "채팅방 ID", example = "1") - @PathVariable("roomId") Long roomId, + @Parameter(description = "스터디 ID", example = "1") + @PathVariable("studyId") Long studyId, @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") int page, diff --git a/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatSwaggerController.java b/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatSwaggerController.java index eadaa3dd..286ceafb 100644 --- a/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatSwaggerController.java +++ b/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatSwaggerController.java @@ -20,9 +20,9 @@ public ApiResponse sendMessage( } - @GetMapping("/sub/chat/room/{roomId}") + @GetMapping("/sub/chat/study/{studyId}") @Override - public ApiResponse> getMessages(@PathVariable Long roomId) { + public ApiResponse> getMessages(@PathVariable Long studyId) { List messages = List.of( new GroupChatSwaggerResponse(), new GroupChatSwaggerResponse() diff --git a/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatSwaggerSpecification.java b/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatSwaggerSpecification.java index 269a5c6e..f26cd8a3 100644 --- a/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatSwaggerSpecification.java +++ b/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatSwaggerSpecification.java @@ -24,7 +24,7 @@ public interface GroupChatSwaggerSpecification { **예시 Request JSON** ```json { - "roomId": 1, + "studyId": 1, "senderId": 10, "message": "안녕하세요!" } @@ -43,16 +43,16 @@ public interface GroupChatSwaggerSpecification { - 먼저 `wss://wibby.cedartodo.uk/ws-stomp` 엔드포인트로 STOMP 연결을 맺습니다. **2. 메시지 구독** - - 연결 후 `/sub/chat/room/{roomId}` 경로를 구독(subscribe)하면 해당 채팅방의 새로운 메시지를 실시간으로 수신할 수 있습니다. + - 연결 후 `/sub/chat/study/{studyId}` 경로를 구독(subscribe)하면 해당 채팅방의 새로운 메시지를 실시간으로 수신할 수 있습니다. **예시 Subscribe 경로** - `/sub/chat/room/1` + `/sub/chat/study/1` **예시 수신 데이터(JSON)** ```json { "id": 101, - "roomId": 1, + "studyId": 1, "senderId": 10, "senderNickname": "유강현", "profileImageUrl": "https://example.com/profile.jpg", @@ -64,5 +64,5 @@ public interface GroupChatSwaggerSpecification { ** Swagger에서는 WebSocket 구독을 테스트할 수 없으며, 이 문서는 프론트엔드 구현 참고용입니다.** """) - ApiResponse> getMessages(Long roomId); + ApiResponse> getMessages(Long studyId); } diff --git a/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatWebSocketController.java b/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatWebSocketController.java index c37e4fc4..5e516b21 100644 --- a/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatWebSocketController.java +++ b/src/main/java/grep/neogulcoder/domain/groupchat/controller/GroupChatWebSocketController.java @@ -28,17 +28,17 @@ public GroupChatWebSocketController(GroupChatService groupChatService, // 클라이언트가 /pub/chat/message 로 보낼 때 처리됨 @MessageMapping("/chat/message") public void handleMessage(GroupChatMessageRequestDto requestDto) { - log.info("[웹소켓] 새 메시지 수신 - 채팅방: {}, 보낸 사람: {}, 내용: {}", - requestDto.getRoomId(), requestDto.getSenderId(), requestDto.getMessage()); + log.info("[웹소켓] 새 메시지 수신 - 스터디: {}, 보낸 사람: {}, 내용: {}", + requestDto.getStudyId(), requestDto.getSenderId(), requestDto.getMessage()); // 메시지를 DB에 저장하고, 응답 DTO 생성 GroupChatMessageResponseDto responseDto = groupChatService.saveMessage(requestDto); - // 구독 중인 클라이언트에게 메시지 전송 (채팅방 구분) - // 클라이언트는 /sub/chat/room/{roomId} 구독 중이어야 실시간으로 수신 가능 - log.info("[웹소켓] 채팅방 {} 구독자에게 메시지 전송 완료", requestDto.getRoomId()); + // 구독 중인 클라이언트에게 메시지 전송 (스터디 구분) + // 클라이언트는 /sub/chat/study/{studyId} 구독 중이어야 실시간으로 수신 가능 + log.info("[웹소켓] 스터디 {} 구독자에게 메시지 전송 완료", requestDto.getStudyId()); messagingTemplate.convertAndSend( - "/sub/chat/room/" + requestDto.getRoomId(), // 메시지를 받을 대상 + "/sub/chat/study/" + requestDto.getStudyId(), // 메시지를 받을 대상 responseDto // 클라이언트에 전달할 응답 메시지 DTO ); } diff --git a/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java b/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java index 67882658..745a14d0 100644 --- a/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java +++ b/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java @@ -9,14 +9,14 @@ @Hidden @Getter public class GroupChatMessageRequestDto { - private Long roomId; + private Long studyId; private Long senderId; private String message; public GroupChatMessageRequestDto() {} - public GroupChatMessageRequestDto(Long roomId, Long senderId, String message) { - this.roomId = roomId; + public GroupChatMessageRequestDto(Long studyId, Long senderId, String message) { + this.studyId = studyId; this.senderId = senderId; this.message = message; } diff --git a/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java b/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java index c148a87f..df2ed862 100644 --- a/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java +++ b/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/requset/GroupChatSwaggerRequest.java @@ -10,8 +10,8 @@ public class GroupChatSwaggerRequest { @Schema(description = "보낸 사람 ID", example = "456") private Long senderId; - @Schema(description = "채팅방 ID", example = "100") - private Long roomId; + @Schema(description = "스터디 ID", example = "100") + private Long studyId; @Schema(description = "보낼 메시지", example = "안녕하세요!") private String message; diff --git a/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java b/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java index 4bf385c8..7b00a9eb 100644 --- a/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java +++ b/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java @@ -11,18 +11,18 @@ public class GroupChatMessageResponseDto { private Long id; // 메시지 고유 ID - private Long roomId; // 채팅방 ID + private Long studyId; // 채팅방 ID private Long senderId; // 보낸 사람 ID private String senderNickname; // 보낸 사람 닉네임 private String profileImageUrl; // 프로필 이미지 URL private String message; // 메시지 내용 private LocalDateTime sentAt; // 보낸 시간 - private GroupChatMessageResponseDto(Long id, Long roomId, Long senderId, + private GroupChatMessageResponseDto(Long id, Long studyId, Long senderId, String senderNickname, String profileImageUrl, String message, LocalDateTime sentAt) { this.id = id; - this.roomId = roomId; + this.studyId = studyId; this.senderId = senderId; this.senderNickname = senderNickname; this.profileImageUrl = profileImageUrl; @@ -33,7 +33,7 @@ private GroupChatMessageResponseDto(Long id, Long roomId, Long senderId, public static GroupChatMessageResponseDto from(GroupChatMessage message, User sender) { return new GroupChatMessageResponseDto( message.getMessageId(), - message.getGroupChatRoom().getRoomId(), + message.getGroupChatRoom().getStudyId(), sender.getId(), sender.getNickname(), sender.getProfileImageUrl(), diff --git a/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java b/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java index 49bf0f3c..dd137ec7 100644 --- a/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java +++ b/src/main/java/grep/neogulcoder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java @@ -20,8 +20,8 @@ public class GroupChatSwaggerResponse { @Schema(description = "프로필 이미지 URL", example = "https://ganghyeon.jpg") private String profileImageUrl; - @Schema(description = "채팅방 ID", example = "100") - private Long roomId; + @Schema(description = "스터디 ID", example = "100") + private Long studyId; @Schema(description = "보낸 메시지", example = "안녕하세요!") private String message; diff --git a/src/main/java/grep/neogulcoder/domain/groupchat/service/GroupChatService.java b/src/main/java/grep/neogulcoder/domain/groupchat/service/GroupChatService.java index cc960d64..2093b905 100644 --- a/src/main/java/grep/neogulcoder/domain/groupchat/service/GroupChatService.java +++ b/src/main/java/grep/neogulcoder/domain/groupchat/service/GroupChatService.java @@ -32,21 +32,20 @@ public class GroupChatService { @Transactional public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto requestDto) { - log.info("[서비스] 메시지 저장 시작 - 채팅방: {}, 보낸 사람: {}", - requestDto.getRoomId(), requestDto.getSenderId()); + log.info("[서비스] 메시지 저장 시작 - 스터디: {}, 보낸 사람: {}", + requestDto.getStudyId(), requestDto.getSenderId()); // 채팅방 존재 여부 확인 - GroupChatRoom room = roomRepository.findById(requestDto.getRoomId()) + GroupChatRoom room = roomRepository.findByStudyId(requestDto.getStudyId()) .orElseThrow(() -> new IllegalArgumentException("채팅방이 존재하지 않습니다.")); // 메시지 발신자(사용자) 정보 조회 User sender = userRepository.findById(requestDto.getSenderId()) .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); - log.info("[서비스] 보낸 사람 '{}' 검증 완료 (roomId={})", sender.getNickname(), room.getRoomId()); + log.info("[서비스] 보낸 사람 '{}' 검증 완료 (studyId={})", sender.getNickname(), room.getStudyId()); // 스터디 참가자 검증 로직 추가 - Long studyId = room.getStudyId(); - boolean isParticipant = studyMemberRepository.existsByStudyIdAndUserId(studyId, sender.getId()); + boolean isParticipant = studyMemberRepository.existsByStudyIdAndUserId(requestDto.getStudyId(), sender.getId()); if (!isParticipant) { throw new IllegalArgumentException("해당 스터디에 참가한 사용자만 채팅할 수 있습니다."); } @@ -56,20 +55,23 @@ public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto reques // 메시지 저장 messageRepository.save(message); - log.info("[서비스] 메시지 저장 완료 - 메시지ID: {}, 채팅방: {}, 시간: {}", - message.getMessageId(), room.getRoomId(), message.getSentAt()); + log.info("[서비스] 메시지 저장 완료 - 메시지ID: {}, 스터디: {}, 시간: {}", + message.getMessageId(), room.getStudyId(), message.getSentAt()); // 응답 dto 생성 return GroupChatMessageResponseDto.from(message, sender); } // 과거 채팅 메시지 페이징 조회 (무한 스크롤용) - public PageResponse getMessages(Long roomId, int page, int size) { + public PageResponse getMessages(Long studyId, int page, int size) { + GroupChatRoom room = roomRepository.findByStudyId(studyId) + .orElseThrow(() -> new IllegalArgumentException("채팅방이 존재하지 않습니다.")); + // 오래된 메시지부터 조회되도록 정렬 기준을 ASC로 설정 Pageable pageable = PageRequest.of(page, size, Sort.by("sentAt").ascending()); // JPQL 쿼리 메서드 기반 조회 - Page messages = messageRepository.findMessagesByRoomIdAsc(roomId, pageable); + Page messages = messageRepository.findMessagesByRoomIdAsc(room.getRoomId(), pageable); // 응답 DTO로 변환 Page messagePage = messages.map(message -> { @@ -81,7 +83,7 @@ public PageResponse getMessages(Long roomId, int pa // PageResponse로 감싸서 반환 return new PageResponse<>( - "/api/chat/room/" + roomId + "/messages", + "/api/chat/study/" + studyId + "/messages", messagePage, 5 // 페이지 버튼 개수 ); diff --git a/src/main/java/grep/neogulcoder/domain/review/ReviewType.java b/src/main/java/grep/neogulcoder/domain/review/ReviewType.java index 32249a4f..d232d71c 100644 --- a/src/main/java/grep/neogulcoder/domain/review/ReviewType.java +++ b/src/main/java/grep/neogulcoder/domain/review/ReviewType.java @@ -21,4 +21,8 @@ public boolean isSameType(ReviewType reviewType) { public boolean isNotSameType(ReviewType reviewType) { return !isSameType(reviewType); } + + public boolean isPositive() { + return this == GOOD || this == EXCELLENT; + } } diff --git a/src/main/java/grep/neogulcoder/domain/review/controller/ReviewController.java b/src/main/java/grep/neogulcoder/domain/review/controller/ReviewController.java index 9a2a7230..a3e0771d 100644 --- a/src/main/java/grep/neogulcoder/domain/review/controller/ReviewController.java +++ b/src/main/java/grep/neogulcoder/domain/review/controller/ReviewController.java @@ -32,8 +32,7 @@ public ApiResponse getReviewTargetUsersInfo(@PathVariable } @GetMapping("/studies/me") - public ApiResponse getReviewTargetStudiesInfo(@AuthenticationPrincipal Principal userDetails, - LocalDateTime currentDateTime) { + public ApiResponse getReviewTargetStudiesInfo(@AuthenticationPrincipal Principal userDetails) { ReviewTargetStudiesInfo response = reviewService.getReviewTargetStudiesInfo(userDetails.getUserId(), LocalDateTime.now()); return ApiResponse.success(response); } diff --git a/src/main/java/grep/neogulcoder/domain/review/controller/ReviewSpecification.java b/src/main/java/grep/neogulcoder/domain/review/controller/ReviewSpecification.java index 484834b9..6e1ab01f 100644 --- a/src/main/java/grep/neogulcoder/domain/review/controller/ReviewSpecification.java +++ b/src/main/java/grep/neogulcoder/domain/review/controller/ReviewSpecification.java @@ -54,7 +54,7 @@ public interface ReviewSpecification { - `studyInfo`: 리뷰 가능한 스터디의 이름과 이미지 정보 리스트입니다. """ ) - ApiResponse getReviewTargetStudiesInfo(Principal userDetails, LocalDateTime currentDateTime); + ApiResponse getReviewTargetStudiesInfo(Principal userDetails); @Operation(summary = "리뷰 생성", description = "스터디에 대한 리뷰를 작성 합니다.") ApiResponse save(ReviewSaveRequest request, Principal userDetails); diff --git a/src/main/java/grep/neogulcoder/domain/review/service/ReviewService.java b/src/main/java/grep/neogulcoder/domain/review/service/ReviewService.java index 97906278..aeb0d84d 100644 --- a/src/main/java/grep/neogulcoder/domain/review/service/ReviewService.java +++ b/src/main/java/grep/neogulcoder/domain/review/service/ReviewService.java @@ -1,6 +1,10 @@ package grep.neogulcoder.domain.review.service; -import grep.neogulcoder.domain.buddy.service.BuddyEnergyService; +import grep.neogulcoder.domain.buddy.entity.BuddyEnergy; +import grep.neogulcoder.domain.buddy.entity.BuddyLog; +import grep.neogulcoder.domain.buddy.exception.BuddyEnergyNotFoundException; +import grep.neogulcoder.domain.buddy.repository.BuddyEnergyLogRepository; +import grep.neogulcoder.domain.buddy.repository.BuddyEnergyRepository; import grep.neogulcoder.domain.review.*; import grep.neogulcoder.domain.review.controller.dto.response.MyReviewTagsInfo; import grep.neogulcoder.domain.review.controller.dto.response.ReviewContentsPagingInfo; @@ -14,8 +18,10 @@ import grep.neogulcoder.domain.review.repository.ReviewRepository; import grep.neogulcoder.domain.review.repository.ReviewTagRepository; import grep.neogulcoder.domain.review.service.request.ReviewSaveServiceRequest; +import grep.neogulcoder.domain.study.Studies; import grep.neogulcoder.domain.study.Study; import grep.neogulcoder.domain.study.StudyMember; +import grep.neogulcoder.domain.study.StudyMembers; import grep.neogulcoder.domain.study.repository.StudyMemberQueryRepository; import grep.neogulcoder.domain.study.repository.StudyMemberRepository; import grep.neogulcoder.domain.study.repository.StudyRepository; @@ -30,11 +36,13 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; +import static grep.neogulcoder.domain.buddy.exception.code.BuddyEnergyErrorCode.BUDDY_ENERGY_NOT_FOUND; import static grep.neogulcoder.domain.review.ReviewErrorCode.ALREADY_REVIEW_WRITE_USER; import static grep.neogulcoder.domain.review.ReviewErrorCode.STUDY_NOT_FOUND; @@ -54,7 +62,8 @@ public class ReviewService { private final MyReviewTagRepository myReviewTagRepository; private final ReviewTagRepository reviewTagRepository; - private final BuddyEnergyService buddyEnergyService; + private final BuddyEnergyRepository buddyEnergyRepository; + private final BuddyEnergyLogRepository buddyEnergyLogRepository; public ReviewTargetUsersInfo getReviewTargetUsersInfo(long studyId, long userId) { List studyMembers = studyMemberRepository.findFetchStudyByStudyId(studyId); @@ -67,39 +76,22 @@ public ReviewTargetUsersInfo getReviewTargetUsersInfo(long studyId, long userId) } public ReviewTargetStudiesInfo getReviewTargetStudiesInfo(long userId, LocalDateTime currentDateTime) { - List studyMembers = studyMemberQueryRepository.findMembersByUserId(userId); - List studies = extractConnectedStudies(studyMembers); - List reviewableStudies = filteredReviewableStudies(currentDateTime, studies); + List studies = findReviewableStudies(userId, currentDateTime); + List studyMembers = findJoinedStudyMemberBy(studies); + List otherMembers = StudyMembers.of(studyMembers).excludeByUser(userId); - List studyIds = reviewableStudies.stream() - .map(Study::getId) - .toList(); - - List studyMemberList = studyMemberQueryRepository.findByIdIn(studyIds); - - - Map groupedStudyIdCountMap = studyMemberList.stream() - .map(StudyMember::getStudy) - .collect( - Collectors.groupingBy( - Study::getId, - Collectors.counting() - ) - ); + List myReviews = reviewQueryRepository.findReviewsByReviewerInStudies( + Studies.of(studies).extractId(), userId + ); - List myReviews = reviewQueryRepository.findReviewsByReviewerInStudies(studyIds, userId); - Map> groupedStudyIdMap = myReviews.stream() - .collect( - Collectors.groupingBy( - ReviewEntity::getStudyId - ) - ); + Map groupedStudyIdCountMap = StudyMembers.of(otherMembers).getGroupedStudyIdCountMap(); + Map> groupedStudyIdMap = getGroupedStudyIdMap(myReviews); - List studies1 = studies.stream() - .filter(study -> groupedStudyIdCountMap.get(study.getId()) - 1 != groupedStudyIdMap.get(study.getId()).size()) + List reviewTargetStudies = studies.stream() + .filter(study -> isCompletedReview(study, groupedStudyIdCountMap, groupedStudyIdMap)) .toList(); - return ReviewTargetStudiesInfo.of(studies1); + return ReviewTargetStudiesInfo.of(reviewTargetStudies); } @Transactional @@ -108,15 +100,16 @@ public long save(ReviewSaveServiceRequest request, long writeUserId) { throw new BusinessException(ALREADY_REVIEW_WRITE_USER); } - Study study = findValidStudy(request.getStudyId()); + Study study = studyRepository.findById(request.getStudyId()) + .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); + ReviewTags reviewTags = ReviewTags.from(convertStringToReviewTag(request.getReviewTag())); ReviewType reviewType = reviewTags.ensureSingleReviewType(); Review review = request.toReview(reviewTags.getReviewTags(), reviewType, writeUserId); List reviewTagEntities = mapToReviewTagEntities(reviewTags); - buddyEnergyService.updateEnergyByReview(request.getTargetUserId(), reviewType); - + updatedBuddyEnergy(writeUserId, reviewType); return reviewRepository.save(ReviewEntity.from(review, reviewTagEntities, study.getId())).getId(); } @@ -160,23 +153,39 @@ private List excludeMe(List users, long userId) { .toList(); } - private List filteredReviewableStudies(LocalDateTime currentDateTime, List studies) { - return studies.stream() + private List findReviewableStudies(long userId, LocalDateTime currentDateTime) { + List myStudyMembers = studyMemberQueryRepository.findMembersByUserId(userId); + List myStudies = extractConnectedStudies(myStudyMembers); + + return myStudies.stream() .filter(study -> study.isReviewableAt(currentDateTime)) .toList(); } + private boolean isCompletedReview(Study study, Map groupedStudyIdCountMap, Map> groupedStudyIdMap) { + return groupedStudyIdCountMap.getOrDefault(study.getId(), 0L) != groupedStudyIdMap.getOrDefault(study.getId(), Collections.emptyList()).size(); + } + + private List findJoinedStudyMemberBy(List studies) { + List studyIds = Studies.of(studies).extractId(); + return studyMemberQueryRepository.findByIdIn(studyIds); + } + + private Map> getGroupedStudyIdMap(List myReviews) { + return myReviews.stream() + .collect( + Collectors.groupingBy( + ReviewEntity::getStudyId + ) + ); + } + private List extractConnectedStudies(List studyMembers) { return studyMembers.stream() .map(StudyMember::getStudy) .toList(); } - private Study findValidStudy(long studyId) { - return studyRepository.findById(studyId) - .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); - } - private boolean isAlreadyWrittenReviewBy(long studyId, long targetUserId, long writeUserId) { return reviewQueryRepository.findBy(studyId, targetUserId, writeUserId) != null; } @@ -192,6 +201,15 @@ private List mapToReviewTagEntities(ReviewTags reviewTags) { return reviewTagRepository.findByReviewTagIn(reviewTagDescriptions); } + private BuddyEnergy updatedBuddyEnergy(long writeUserId, ReviewType reviewType) { + BuddyEnergy energy = buddyEnergyRepository.findByUserId(writeUserId) + .orElseThrow(() -> new BuddyEnergyNotFoundException(BUDDY_ENERGY_NOT_FOUND)); + + energy.updateEnergy(reviewType); + buddyEnergyLogRepository.save(BuddyLog.of(energy, energy.findReasonFrom(reviewType))); + return energy; + } + private List extractReviewTags(List myReviews) { List reviewTagEntities = getReviewTagEntities(myReviews); return reviewTagEntities.stream() diff --git a/src/main/java/grep/neogulcoder/domain/study/Studies.java b/src/main/java/grep/neogulcoder/domain/study/Studies.java new file mode 100644 index 00000000..5422f363 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/study/Studies.java @@ -0,0 +1,22 @@ +package grep.neogulcoder.domain.study; + +import java.util.List; + +public class Studies { + + private final List studies; + + private Studies(List studies) { + this.studies = studies; + } + + public static Studies of(List studies){ + return new Studies(studies); + } + + public List extractId(){ + return this.studies.stream() + .map(Study::getId) + .toList(); + } +} diff --git a/src/main/java/grep/neogulcoder/domain/study/Study.java b/src/main/java/grep/neogulcoder/domain/study/Study.java index 7635af2a..2d40b39b 100644 --- a/src/main/java/grep/neogulcoder/domain/study/Study.java +++ b/src/main/java/grep/neogulcoder/domain/study/Study.java @@ -17,6 +17,8 @@ public class Study extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + private Long userId; + private Long originStudyId; private String name; @@ -49,8 +51,9 @@ protected Study() { } @Builder - private Study(Long originStudyId, String name, Category category, int capacity, StudyType studyType, String location, + private Study(Long userId, Long originStudyId, String name, Category category, int capacity, StudyType studyType, String location, LocalDateTime startDate, LocalDateTime endDate, String introduction, String imageUrl) { + this.userId = userId; this.originStudyId = originStudyId; this.name = name; this.category = category; diff --git a/src/main/java/grep/neogulcoder/domain/study/StudyMember.java b/src/main/java/grep/neogulcoder/domain/study/StudyMember.java index 95c985eb..20d5bb37 100644 --- a/src/main/java/grep/neogulcoder/domain/study/StudyMember.java +++ b/src/main/java/grep/neogulcoder/domain/study/StudyMember.java @@ -36,14 +36,22 @@ public StudyMember(Study study, Long userId, StudyMemberRole role) { this.participated = false; } - public static StudyMember createMember(Study study, Long userId) { + public static StudyMember createLeader(Study study, Long userId) { return StudyMember.builder() .study(study) .userId(userId) - .role(StudyMemberRole.MEMBER) + .role(StudyMemberRole.LEADER) .build(); } + public static StudyMember createMember(Study study, Long userId) { + return StudyMember.builder() + .study(study) + .userId(userId) + .role(StudyMemberRole.MEMBER) + .build(); + } + public void delete() { this.activated = false; } diff --git a/src/main/java/grep/neogulcoder/domain/study/StudyMembers.java b/src/main/java/grep/neogulcoder/domain/study/StudyMembers.java new file mode 100644 index 00000000..ffe493d4 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/study/StudyMembers.java @@ -0,0 +1,35 @@ +package grep.neogulcoder.domain.study; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class StudyMembers { + + private List studyMembers; + + private StudyMembers(List studyMembers) { + this.studyMembers = studyMembers; + } + + public static StudyMembers of(List studyMembers){ + return new StudyMembers(studyMembers); + } + + public List excludeByUser(long userId){ + return this.studyMembers.stream() + .filter(studyMember -> studyMember.getUserId() != userId) + .toList(); + } + + public Map getGroupedStudyIdCountMap() { + return this.studyMembers.stream() + .map(StudyMember::getStudy) + .collect( + Collectors.groupingBy( + Study::getId, + Collectors.counting() + ) + ); + } +} diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/StudyManagementController.java b/src/main/java/grep/neogulcoder/domain/study/controller/StudyManagementController.java index d6a2757a..19f4cd6e 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/StudyManagementController.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/StudyManagementController.java @@ -62,4 +62,11 @@ public ApiResponse registerExtensionParticipation(@PathVariable("studyId") studyManagementService.registerExtensionParticipation(studyId, userDetails.getUserId()); return ApiResponse.noContent(); } + + @PostMapping("/invite/user") + public ApiResponse inviteUser(@PathVariable("studyId") Long studyId, @AuthenticationPrincipal Principal userDetails, String targetUserNickname) { + studyManagementService.inviteTargetUser(studyId, userDetails.getUserId(), targetUserNickname); + return ApiResponse.noContent(); + } + } diff --git a/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/StudyCreateRequest.java b/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/StudyCreateRequest.java index 4d961223..efa36cba 100644 --- a/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/StudyCreateRequest.java +++ b/src/main/java/grep/neogulcoder/domain/study/controller/dto/request/StudyCreateRequest.java @@ -60,8 +60,9 @@ private StudyCreateRequest(String name, Category category, int capacity, StudyTy this.introduction = introduction; } - public Study toEntity(String imageUrl) { + public Study toEntity(Long userId, String imageUrl) { return Study.builder() + .userId(userId) .name(this.name) .category(this.category) .capacity(this.capacity) diff --git a/src/main/java/grep/neogulcoder/domain/study/event/StudyInviteEvent.java b/src/main/java/grep/neogulcoder/domain/study/event/StudyInviteEvent.java new file mode 100644 index 00000000..533761d0 --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/study/event/StudyInviteEvent.java @@ -0,0 +1,5 @@ +package grep.neogulcoder.domain.study.event; + +public record StudyInviteEvent(Long studyId, Long inviterId, Long targetUserId) { + +} diff --git a/src/main/java/grep/neogulcoder/domain/study/exception/code/StudyErrorCode.java b/src/main/java/grep/neogulcoder/domain/study/exception/code/StudyErrorCode.java index 230426d8..527df8a8 100644 --- a/src/main/java/grep/neogulcoder/domain/study/exception/code/StudyErrorCode.java +++ b/src/main/java/grep/neogulcoder/domain/study/exception/code/StudyErrorCode.java @@ -11,18 +11,19 @@ public enum StudyErrorCode implements ErrorCode { STUDY_MEMBER_NOT_FOUND("S002", HttpStatus.NOT_FOUND, "스터디 멤버가 아닙니다."), EXTENDED_STUDY_NOT_FOUND("S003", HttpStatus.NOT_FOUND, "연장된 스터디를 찾을 수 없습니다."), - STUDY_ALREADY_STARTED("S004", HttpStatus.BAD_REQUEST, "이미 시작된 스터디의 시작일은 변경할 수 없습니다."), - STUDY_DELETE_NOT_ALLOWED("S005", HttpStatus.BAD_REQUEST, "스터디 멤버가 1명일 때만 삭제할 수 있습니다."), - STUDY_LOCATION_REQUIRED("S006", HttpStatus.BAD_REQUEST, "스터디 타입이 OFFLINE이나 HYBRID인 스터디는 지역 입력이 필수입니다."), - - STUDY_EXTENSION_NOT_AVAILABLE("S007", HttpStatus.BAD_REQUEST, "스터디 연장은 스터디 종료일 7일 전부터 가능합니다."), - END_DATE_BEFORE_ORIGIN_STUDY("S008", HttpStatus.BAD_REQUEST, "연장 스터디 종료일은 기존 스터디 종료일 이후여야 합니다."), - ALREADY_EXTENDED_STUDY("S009", HttpStatus.BAD_REQUEST, "이미 연장된 스터디입니다."), - ALREADY_REGISTERED_PARTICIPATION("S010", HttpStatus.BAD_REQUEST, "연장 스터디 참여는 한 번만 등록할 수 있습니다."), - - NOT_STUDY_LEADER("S011", HttpStatus.FORBIDDEN, "스터디장만 접근이 가능합니다."), - LEADER_CANNOT_LEAVE_STUDY("S012", HttpStatus.BAD_REQUEST, "스터디장은 스터디를 탈퇴할 수 없습니다."), - LEADER_CANNOT_DELEGATE_TO_SELF("S013", HttpStatus.BAD_REQUEST, "자기 자신에게는 스터디장 위임이 불가능합니다."); + STUDY_CREATE_LIMIT_EXCEEDED("S004", HttpStatus.BAD_REQUEST, "종료되지 않은 스터디는 최대 10개까지만 생성할 수 있습니다."), + STUDY_ALREADY_STARTED("S005", HttpStatus.BAD_REQUEST, "이미 시작된 스터디의 시작일은 변경할 수 없습니다."), + STUDY_DELETE_NOT_ALLOWED("S006", HttpStatus.BAD_REQUEST, "스터디 멤버가 1명일 때만 삭제할 수 있습니다."), + STUDY_LOCATION_REQUIRED("S007", HttpStatus.BAD_REQUEST, "스터디 타입이 OFFLINE이나 HYBRID인 스터디는 지역 입력이 필수입니다."), + + STUDY_EXTENSION_NOT_AVAILABLE("S008", HttpStatus.BAD_REQUEST, "스터디 연장은 스터디 종료일 7일 전부터 가능합니다."), + END_DATE_BEFORE_ORIGIN_STUDY("S009", HttpStatus.BAD_REQUEST, "연장 스터디 종료일은 기존 스터디 종료일 이후여야 합니다."), + ALREADY_EXTENDED_STUDY("S010", HttpStatus.BAD_REQUEST, "이미 연장된 스터디입니다."), + ALREADY_REGISTERED_PARTICIPATION("S011", HttpStatus.BAD_REQUEST, "연장 스터디 참여는 한 번만 등록할 수 있습니다."), + + NOT_STUDY_LEADER("S012", HttpStatus.FORBIDDEN, "스터디장만 접근이 가능합니다."), + LEADER_CANNOT_LEAVE_STUDY("S013", HttpStatus.BAD_REQUEST, "스터디장은 스터디를 탈퇴할 수 없습니다."), + LEADER_CANNOT_DELEGATE_TO_SELF("S014", HttpStatus.BAD_REQUEST, "자기 자신에게는 스터디장 위임이 불가능합니다."); private final String code; private final HttpStatus status; diff --git a/src/main/java/grep/neogulcoder/domain/study/repository/StudyMemberQueryRepository.java b/src/main/java/grep/neogulcoder/domain/study/repository/StudyMemberQueryRepository.java index c52e8d24..3c2d626b 100644 --- a/src/main/java/grep/neogulcoder/domain/study/repository/StudyMemberQueryRepository.java +++ b/src/main/java/grep/neogulcoder/domain/study/repository/StudyMemberQueryRepository.java @@ -1,6 +1,7 @@ package grep.neogulcoder.domain.study.repository; import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import grep.neogulcoder.domain.study.StudyMember; import grep.neogulcoder.domain.study.controller.dto.response.ExtendParticipationResponse; @@ -75,10 +76,29 @@ public List findExtendParticipation(Long studyId) { public List findByIdIn(List studyIds) { return queryFactory.selectFrom(studyMember) .where( - studyMember.id.in(studyIds), + studyMember.study.id.in(studyIds), studyMember.activated.isTrue() ) .join(studyMember.study, study).fetchJoin() .fetch(); } + + public int countActiveUnfinishedStudies(Long userId) { + BooleanExpression notExtendedAndParticipate = study.extended.isFalse().and(studyMember.activated.isTrue()); + BooleanExpression extendedAndNotParticipate = study.extended.isTrue().and(studyMember.participated.isFalse()); + + Long result = queryFactory + .select(studyMember.count()) + .from(studyMember) + .join(studyMember.study, study) + .where( + studyMember.userId.eq(userId), + study.activated.isTrue(), + study.finished.isFalse(), + notExtendedAndParticipate.or(extendedAndNotParticipate) + ) + .fetchOne(); + + return result != null ? result.intValue() : 0; + } } diff --git a/src/main/java/grep/neogulcoder/domain/study/repository/StudyRepository.java b/src/main/java/grep/neogulcoder/domain/study/repository/StudyRepository.java index a287e040..f4134e5f 100644 --- a/src/main/java/grep/neogulcoder/domain/study/repository/StudyRepository.java +++ b/src/main/java/grep/neogulcoder/domain/study/repository/StudyRepository.java @@ -26,4 +26,6 @@ public interface StudyRepository extends JpaRepository { @Query("select s from Study s where s.endDate < :now and s.finished = false and s.activated = true") List findStudiesToBeFinished(@Param("now") LocalDateTime now); + + int countByUserIdAndActivatedTrueAndFinishedFalse(Long userId); } diff --git a/src/main/java/grep/neogulcoder/domain/study/service/StudyManagementService.java b/src/main/java/grep/neogulcoder/domain/study/service/StudyManagementService.java index 6474133f..03d08145 100644 --- a/src/main/java/grep/neogulcoder/domain/study/service/StudyManagementService.java +++ b/src/main/java/grep/neogulcoder/domain/study/service/StudyManagementService.java @@ -5,23 +5,36 @@ import grep.neogulcoder.domain.study.controller.dto.request.ExtendStudyRequest; import grep.neogulcoder.domain.study.controller.dto.response.ExtendParticipationResponse; import grep.neogulcoder.domain.study.controller.dto.response.StudyExtensionResponse; +import grep.neogulcoder.domain.study.event.StudyInviteEvent; import grep.neogulcoder.domain.study.repository.StudyMemberQueryRepository; import grep.neogulcoder.domain.study.repository.StudyMemberRepository; import grep.neogulcoder.domain.study.repository.StudyRepository; +import grep.neogulcoder.domain.users.entity.User; +import grep.neogulcoder.domain.users.exception.code.UserErrorCode; +import grep.neogulcoder.domain.users.repository.UserRepository; import grep.neogulcoder.global.exception.business.BusinessException; import grep.neogulcoder.global.exception.business.NotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Random; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import static grep.neogulcoder.domain.study.enums.StudyMemberRole.LEADER; import static grep.neogulcoder.domain.study.enums.StudyMemberRole.MEMBER; -import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.*; +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.ALREADY_EXTENDED_STUDY; +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.ALREADY_REGISTERED_PARTICIPATION; +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.END_DATE_BEFORE_ORIGIN_STUDY; +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.EXTENDED_STUDY_NOT_FOUND; +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.LEADER_CANNOT_DELEGATE_TO_SELF; +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.LEADER_CANNOT_LEAVE_STUDY; +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.NOT_STUDY_LEADER; +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.STUDY_EXTENSION_NOT_AVAILABLE; +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.STUDY_MEMBER_NOT_FOUND; +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.STUDY_NOT_FOUND; @Transactional(readOnly = true) @RequiredArgsConstructor @@ -31,11 +44,14 @@ public class StudyManagementService { private final StudyRepository studyRepository; private final StudyMemberRepository studyMemberRepository; private final StudyMemberQueryRepository studyMemberQueryRepository; + private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; public StudyExtensionResponse getStudyExtension(Long studyId) { Study study = findValidStudy(studyId); - List members = studyMemberQueryRepository.findExtendParticipation(studyId); + List members = studyMemberQueryRepository.findExtendParticipation( + studyId); return StudyExtensionResponse.from(study, members); } @@ -146,6 +162,18 @@ public void registerExtensionParticipation(Long studyId, Long userId) { studyMemberRepository.save(extendMember); } + @Transactional + public void inviteTargetUser(Long studyId, Long userId, String targetUserNickname) { + StudyMember studyMember = findValidStudyMember(studyId, userId); + studyMember.isLeader(); + + User targetUser = userRepository.findByNickname(targetUserNickname) + .orElseThrow(() -> new NotFoundException( + UserErrorCode.USER_NOT_FOUND)); + + eventPublisher.publishEvent(new StudyInviteEvent(studyId, userId, targetUser.getId())); + } + private Study findValidStudy(Long studyId) { return studyRepository.findById(studyId) .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); @@ -157,7 +185,8 @@ private StudyMember findValidStudyMember(Long studyId, Long userId) { } private boolean isLastMember(Study study) { - int activatedMemberCount = studyMemberRepository.countByStudyIdAndActivatedTrue(study.getId()); + int activatedMemberCount = studyMemberRepository.countByStudyIdAndActivatedTrue( + study.getId()); return activatedMemberCount == 1; } @@ -196,4 +225,5 @@ private void validateStudyExtendable(Study study, LocalDateTime endDate) { throw new BusinessException(END_DATE_BEFORE_ORIGIN_STUDY); } } + } diff --git a/src/main/java/grep/neogulcoder/domain/study/service/StudyService.java b/src/main/java/grep/neogulcoder/domain/study/service/StudyService.java index 0398cad4..707ac0f6 100644 --- a/src/main/java/grep/neogulcoder/domain/study/service/StudyService.java +++ b/src/main/java/grep/neogulcoder/domain/study/service/StudyService.java @@ -61,19 +61,13 @@ public class StudyService { private final TeamCalendarService teamCalendarService; private final GroupChatRoomRepository groupChatRoomRepository; - public StudyItemPagingResponse getMyStudiesPaging(Pageable pageable, Long userId, Boolean finished) { Page page = studyQueryRepository.findMyStudiesPaging(pageable, userId, finished); return StudyItemPagingResponse.of(page); } public List getMyUnfinishedStudies(Long userId) { - List myUnfinishedStudies = studyQueryRepository.findMyUnfinishedStudies(userId); - return myUnfinishedStudies; - } - - public List getMyStudies(Long userId) { - return studyQueryRepository.findMyStudies(userId); + return studyQueryRepository.findMyUnfinishedStudies(userId); } public StudyHeaderResponse getStudyHeader(Long studyId) { @@ -90,10 +84,7 @@ public StudyResponse getStudy(Long studyId) { progressDays = Math.max(0, Math.min(progressDays, totalDays)); int totalPostCount = studyPostRepository.countByStudyIdAndActivatedTrue(studyId); - LocalDate now = LocalDate.now(); - int currentYear = now.getYear(); - int currentMonth = now.getMonthValue(); - List teamCalendars = teamCalendarService.findByMonth(studyId, currentYear, currentMonth); + List teamCalendars = getCurrentMonthTeamCalendars(studyId); List noticePosts = studyPostQueryRepository.findLatestNoticeInfoBy(studyId); List freePosts = studyPostQueryRepository.findLatestFreeInfoBy(studyId); @@ -121,8 +112,7 @@ public StudyInfoResponse getMyStudyContent(Long studyId, Long userId) { } public StudyMemberInfoResponse getMyStudyMemberInfo(Long studyId, Long userId) { - StudyMember studyMember = Optional.ofNullable(studyMemberQueryRepository.findByStudyIdAndUserId(studyId, userId)) - .orElseThrow(() -> new NotFoundException(STUDY_MEMBER_NOT_FOUND)); + StudyMember studyMember = findValidStudyMember(studyId, userId); User user = userRepository.findById(userId) .orElseThrow(() -> new NotFoundException(USER_NOT_FOUND)); @@ -132,20 +122,16 @@ public StudyMemberInfoResponse getMyStudyMemberInfo(Long studyId, Long userId) { @Transactional public Long createStudy(StudyCreateRequest request, Long userId, MultipartFile image) throws IOException { + validateStudyCreateLimit(userId); validateLocation(request.getStudyType(), request.getLocation()); String imageUrl = createImageUrl(userId, image); - Study study = studyRepository.save(request.toEntity(imageUrl)); + Study study = studyRepository.save(request.toEntity(userId, imageUrl)); - StudyMember leader = StudyMember.builder() - .study(study) - .userId(userId) - .role(LEADER) - .build(); + StudyMember leader = StudyMember.createLeader(study, userId); studyMemberRepository.save(leader); - // 3. 그룹 채팅방 생성 GroupChatRoom chatRoom = new GroupChatRoom(study.getId()); groupChatRoomRepository.save(chatRoom); @@ -188,24 +174,36 @@ public void deleteStudy(Long studyId, Long userId) { recruitmentPostRepository.deactivateByStudyId(studyId); } - @Transactional - public void deleteStudyByAdmin(Long studyId) { - Study study = findValidStudy(studyId); - - study.delete(); - } - private Study findValidStudy(Long studyId) { return studyRepository.findById(studyId) .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); } + private StudyMember findValidStudyMember(Long studyId, Long userId) { + return Optional.ofNullable(studyMemberQueryRepository.findByStudyIdAndUserId(studyId, userId)) + .orElseThrow(() -> new NotFoundException(STUDY_MEMBER_NOT_FOUND)); + } + + private void validateStudyCreateLimit(Long userId) { + int count = studyRepository.countByUserIdAndActivatedTrueAndFinishedFalse(userId); + if (count >= 10) { + throw new BusinessException(STUDY_CREATE_LIMIT_EXCEEDED); + } + } + private static void validateLocation(StudyType studyType, String location) { if ((studyType == StudyType.OFFLINE || studyType == StudyType.HYBRID) && (location == null || location.isBlank())) { throw new BusinessException(STUDY_LOCATION_REQUIRED); } } + private List getCurrentMonthTeamCalendars(Long studyId) { + LocalDate now = LocalDate.now(); + int currentYear = now.getYear(); + int currentMonth = now.getMonthValue(); + return teamCalendarService.findByMonth(studyId, currentYear, currentMonth); + } + private void validateStudyMember(Long studyId, Long userId) { boolean exists = studyMemberRepository.existsByStudyIdAndUserIdAndActivatedTrue(studyId, userId); if (!exists) { @@ -234,9 +232,8 @@ private void validateStudyDeletable(Long studyId) { } private String createImageUrl(Long userId, MultipartFile image) throws IOException { - FileUploadResponse response = null; if (isImgExists(image)) { - response = fileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId); + FileUploadResponse response = fileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId); return response.getFileUrl(); } return null; @@ -254,4 +251,7 @@ private boolean isImgExists(MultipartFile image) { return image != null && !image.isEmpty(); } + private int getActiveUnfinishedStudiesCount(Long userId) { + return studyMemberQueryRepository.countActiveUnfinishedStudies(userId); + } } diff --git a/src/main/java/grep/neogulcoder/domain/studyapplication/exception/code/ApplicationErrorCode.java b/src/main/java/grep/neogulcoder/domain/studyapplication/exception/code/ApplicationErrorCode.java index fc5bd369..879c0867 100644 --- a/src/main/java/grep/neogulcoder/domain/studyapplication/exception/code/ApplicationErrorCode.java +++ b/src/main/java/grep/neogulcoder/domain/studyapplication/exception/code/ApplicationErrorCode.java @@ -13,7 +13,10 @@ public enum ApplicationErrorCode implements ErrorCode { LEADER_CANNOT_APPLY("SA004", HttpStatus.BAD_REQUEST, "스터디장은 스터디를 신청할 수 없습니다."), LEADER_ONLY_APPROVED("SA005", HttpStatus.BAD_REQUEST, "스터디장만 승인이 가능합니다."), - LEADER_ONLY_REJECTED("SA006", HttpStatus.BAD_REQUEST, "스터디장만 거절이 가능합니다."); + LEADER_ONLY_REJECTED("SA006", HttpStatus.BAD_REQUEST, "스터디장만 거절이 가능합니다."), + + APPLICATION_PARTICIPATION_LIMIT_EXCEEDED("SA005", HttpStatus.BAD_REQUEST, "종료되지 않은 스터디는 최대 10개까지만 참여할 수 있습니다."), + APPLICATION_PARTICIPANT_LIMIT_EXCEEDED("SA006", HttpStatus.BAD_REQUEST, "해당 사용자가 이미 10개의 스터디에 참여 중입니다."); private final String code; private final HttpStatus status; diff --git a/src/main/java/grep/neogulcoder/domain/studyapplication/service/ApplicationService.java b/src/main/java/grep/neogulcoder/domain/studyapplication/service/ApplicationService.java index d6bbdf77..8c0a232b 100644 --- a/src/main/java/grep/neogulcoder/domain/studyapplication/service/ApplicationService.java +++ b/src/main/java/grep/neogulcoder/domain/studyapplication/service/ApplicationService.java @@ -5,6 +5,7 @@ import grep.neogulcoder.domain.study.Study; import grep.neogulcoder.domain.study.StudyMember; import grep.neogulcoder.domain.study.enums.StudyMemberRole; +import grep.neogulcoder.domain.study.repository.StudyMemberQueryRepository; import grep.neogulcoder.domain.study.repository.StudyMemberRepository; import grep.neogulcoder.domain.study.repository.StudyRepository; import grep.neogulcoder.domain.studyapplication.ApplicationStatus; @@ -24,9 +25,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import static grep.neogulcoder.domain.recruitment.RecruitmentErrorCode.NOT_FOUND; -import static grep.neogulcoder.domain.recruitment.RecruitmentErrorCode.NOT_OWNER; -import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.STUDY_NOT_FOUND; +import static grep.neogulcoder.domain.recruitment.RecruitmentErrorCode.*; +import static grep.neogulcoder.domain.study.exception.code.StudyErrorCode.*; import static grep.neogulcoder.domain.studyapplication.exception.code.ApplicationErrorCode.*; @Transactional(readOnly = true) @@ -39,6 +39,7 @@ public class ApplicationService { private final RecruitmentPostRepository recruitmentPostRepository; private final StudyMemberRepository studyMemberRepository; private final StudyRepository studyRepository; + private final StudyMemberQueryRepository studyMemberQueryRepository; @Transactional public ReceivedApplicationPagingResponse getReceivedApplicationsPaging(Long recruitmentPostId, Pageable pageable, Long userId) { @@ -62,6 +63,7 @@ public Long createApplication(Long recruitmentPostId, ApplicationCreateRequest r validateNotLeaderApply(recruitmentPost, userId); validateNotAlreadyApplied(recruitmentPostId, userId); + validateApplicantStudyLimit(userId); StudyApplication application = request.toEntity(recruitmentPostId, userId); applicationRepository.save(application); @@ -77,6 +79,7 @@ public void approveApplication(Long applicationId, Long userId) { validateOnlyLeaderCanApprove(study, userId); validateStatusIsApplying(application); + validateParticipantStudyLimit(application.getUserId()); application.approve(); @@ -154,4 +157,18 @@ private void validateOnlyLeaderCanReject(Study study, Long userId) { throw new BusinessException(LEADER_ONLY_REJECTED); } } + + private void validateApplicantStudyLimit(Long userId) { + int count = studyMemberQueryRepository.countActiveUnfinishedStudies(userId); + if (count >= 10) { + throw new BusinessException(APPLICATION_PARTICIPATION_LIMIT_EXCEEDED); + } + } + + private void validateParticipantStudyLimit(Long userId) { + int count = studyMemberQueryRepository.countActiveUnfinishedStudies(userId); + if (count >= 10) { + throw new BusinessException(APPLICATION_PARTICIPANT_LIMIT_EXCEEDED); + } + } } diff --git a/src/main/java/grep/neogulcoder/domain/studypost/controller/dto/response/FreePostInfo.java b/src/main/java/grep/neogulcoder/domain/studypost/controller/dto/response/FreePostInfo.java index 49867bb0..1f5f3abd 100644 --- a/src/main/java/grep/neogulcoder/domain/studypost/controller/dto/response/FreePostInfo.java +++ b/src/main/java/grep/neogulcoder/domain/studypost/controller/dto/response/FreePostInfo.java @@ -5,7 +5,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; -import java.time.LocalDate; import java.time.LocalDateTime; @Getter @@ -21,13 +20,13 @@ public class FreePostInfo { private String title; @Schema(example = "2025-07-21", description = "생성일") - private LocalDate createdAt; + private LocalDateTime createdAt; @QueryProjection public FreePostInfo(long postId, Category category, String title, LocalDateTime createdAt) { this.postId = postId; this.category = category.getKorean(); this.title = title; - this.createdAt = createdAt.toLocalDate(); + this.createdAt = createdAt; } } diff --git a/src/main/java/grep/neogulcoder/domain/studypost/controller/dto/response/NoticePostInfo.java b/src/main/java/grep/neogulcoder/domain/studypost/controller/dto/response/NoticePostInfo.java index da931b09..58b09857 100644 --- a/src/main/java/grep/neogulcoder/domain/studypost/controller/dto/response/NoticePostInfo.java +++ b/src/main/java/grep/neogulcoder/domain/studypost/controller/dto/response/NoticePostInfo.java @@ -5,7 +5,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; -import java.time.LocalDate; import java.time.LocalDateTime; @Getter @@ -21,13 +20,13 @@ public class NoticePostInfo { private String title; @Schema(example = "2025-07-21", description = "생성일") - private LocalDate createdAt; + private LocalDateTime createdAt; @QueryProjection public NoticePostInfo(long postId, Category category, String title, LocalDateTime createdAt) { this.postId = postId; this.category = category.getKorean(); this.title = title; - this.createdAt = createdAt.toLocalDate(); + this.createdAt = createdAt; } } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteController.java b/src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteController.java index cf45102e..5c7efe53 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteController.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/controller/TimeVoteController.java @@ -41,8 +41,8 @@ public ApiResponse createPeriod( @RequestBody @Valid TimeVotePeriodCreateRequest request, @AuthenticationPrincipal Principal userDetails ) { - TimeVotePeriod saved = timeVotePeriodService.createTimeVotePeriodAndReturn(request, studyId, userDetails.getUserId()); - return ApiResponse.success(TimeVotePeriodResponse.from(saved)); + TimeVotePeriodResponse response = timeVotePeriodService.createTimeVotePeriodAndReturn(request, studyId, userDetails.getUserId()); + return ApiResponse.success(response); } @GetMapping("/votes") diff --git a/src/main/java/grep/neogulcoder/domain/timevote/dto/request/TimeVotePeriodCreateRequest.java b/src/main/java/grep/neogulcoder/domain/timevote/dto/request/TimeVotePeriodCreateRequest.java index 7e2cbe37..99986fd9 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/dto/request/TimeVotePeriodCreateRequest.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/dto/request/TimeVotePeriodCreateRequest.java @@ -27,11 +27,15 @@ private TimeVotePeriodCreateRequest(LocalDateTime startDate, LocalDateTime endDa this.endDate = endDate; } - public TimeVotePeriod toEntity(Long studyId) { + public TimeVotePeriod toEntity(Long studyId, LocalDateTime adjustedEndDate) { return TimeVotePeriod.builder() .startDate(this.startDate) - .endDate(this.endDate) + .endDate(adjustedEndDate) .studyId(studyId) .build(); } + + public static TimeVotePeriodCreateRequest of(LocalDateTime startDate, LocalDateTime endDate) { + return new TimeVotePeriodCreateRequest(startDate, endDate); + } } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java b/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java index 3c6d3272..fb146405 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/entity/TimeVoteStat.java @@ -33,15 +33,17 @@ public class TimeVoteStat extends BaseEntity { private Long voteCount; @Version + @Column(nullable = false) private Long version; protected TimeVoteStat() {}; @Builder - public TimeVoteStat(TimeVotePeriod period, LocalDateTime timeSlot, Long voteCount) { + public TimeVoteStat(TimeVotePeriod period, LocalDateTime timeSlot, Long voteCount, Long version) { this.period = period; this.timeSlot = timeSlot; this.voteCount = voteCount; + this.version = version; } public static TimeVoteStat of(TimeVotePeriod period, LocalDateTime timeSlot, Long voteCount) { @@ -49,6 +51,7 @@ public static TimeVoteStat of(TimeVotePeriod period, LocalDateTime timeSlot, Lon .period(period) .timeSlot(timeSlot) .voteCount(voteCount) + .version(0L) .build(); } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatQueryRepository.java b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatQueryRepository.java index 1518c313..0b68ded5 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatQueryRepository.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/repository/TimeVoteStatQueryRepository.java @@ -51,6 +51,7 @@ public void incrementOrInsert(TimeVotePeriod period, LocalDateTime slot, Long co } else { TimeVoteStat newStat = TimeVoteStat.of(period, slot, countToAdd); em.persist(newStat); + em.flush(); } } } diff --git a/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVotePeriodService.java b/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVotePeriodService.java index 45e73bff..668b5fce 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVotePeriodService.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVotePeriodService.java @@ -8,12 +8,14 @@ import grep.neogulcoder.domain.study.repository.StudyMemberRepository; import grep.neogulcoder.domain.study.repository.StudyRepository; import grep.neogulcoder.domain.timevote.dto.request.TimeVotePeriodCreateRequest; +import grep.neogulcoder.domain.timevote.dto.response.TimeVotePeriodResponse; import grep.neogulcoder.domain.timevote.entity.TimeVotePeriod; import grep.neogulcoder.domain.timevote.repository.TimeVotePeriodRepository; import grep.neogulcoder.domain.timevote.repository.TimeVoteRepository; import grep.neogulcoder.domain.timevote.repository.TimeVoteStatRepository; import grep.neogulcoder.global.exception.business.BusinessException; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.temporal.ChronoUnit; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -25,12 +27,12 @@ public class TimeVotePeriodService { private final TimeVotePeriodRepository timeVotePeriodRepository; - private final StudyRepository studyRepository; private final TimeVoteRepository timeVoteRepository; private final TimeVoteStatRepository timeVoteStatRepository; + private final StudyRepository studyRepository; private final StudyMemberRepository studyMemberRepository; - public TimeVotePeriod createTimeVotePeriodAndReturn(TimeVotePeriodCreateRequest request, Long studyId, Long userId) { + public TimeVotePeriodResponse createTimeVotePeriodAndReturn(TimeVotePeriodCreateRequest request, Long studyId, Long userId) { StudyMember studyMember = getValidStudyMember(studyId, userId); validateStudyLeader(studyMember); @@ -38,18 +40,19 @@ public TimeVotePeriod createTimeVotePeriodAndReturn(TimeVotePeriodCreateRequest validatePeriodRange(request.getStartDate(), request.getEndDate()); validateMaxPeriod(request.getStartDate(), request.getEndDate()); + LocalDateTime adjustedEndDate = adjustEndDate(request.getEndDate()); + if (timeVotePeriodRepository.existsByStudyId(studyId)) { deleteAllTimeVoteDate(studyId); } - TimeVotePeriod savedPeriod = timeVotePeriodRepository.save(request.toEntity(studyId)); + TimeVotePeriod savedPeriod = timeVotePeriodRepository.save(request.toEntity(studyId, adjustedEndDate)); - return savedPeriod; + return TimeVotePeriodResponse.from(savedPeriod); } public void deleteAllTimeVoteDate(Long studyId) { - Study study = studyRepository.findById(studyId) - .orElseThrow(() -> new BusinessException(STUDY_NOT_FOUND)); + getValidStudy(studyId); timeVoteRepository.deleteAllByPeriod_StudyId(studyId); timeVoteStatRepository.deleteAllByPeriod_StudyId(studyId); @@ -57,6 +60,12 @@ public void deleteAllTimeVoteDate(Long studyId) { } // ================================= 검증 로직 ================================ + // 주어진 ID의 스터디가 존재하는지 검증 (존재하지 않으면 예외 발생) + private Study getValidStudy(Long studyId) { + return studyRepository.findById(studyId) + .orElseThrow(() -> new BusinessException(STUDY_NOT_FOUND)); + } + // 유효한 스터디 멤버인지 확인 (활성화된 멤버만 허용) private StudyMember getValidStudyMember(Long studyId, Long userId) { return studyMemberRepository.findByStudyIdAndUserIdAndActivatedTrue(studyId, userId) @@ -79,6 +88,11 @@ private void validateMaxPeriod(LocalDateTime startDate, LocalDateTime endDate) { } } + // 투표 기간 종료일을 마지막 날 23:59:59로 보정 + private LocalDateTime adjustEndDate(LocalDateTime endDate) { + return endDate.with(LocalTime.of(23, 59, 59)); + } + // 시작일이 종료일보다 늦은 비정상적인 입력 방지 private void validatePeriodRange(LocalDateTime startDate, LocalDateTime endDate) { if (startDate.isAfter(endDate)) { diff --git a/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteService.java b/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteService.java index ca1f7c82..2e9d8aba 100644 --- a/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteService.java +++ b/src/main/java/grep/neogulcoder/domain/timevote/service/TimeVoteService.java @@ -15,6 +15,7 @@ import grep.neogulcoder.domain.timevote.repository.TimeVoteQueryRepository; import grep.neogulcoder.global.exception.business.BusinessException; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -119,7 +120,8 @@ private TimeVotePeriod getValidTimeVotePeriod(Long studyId) { // 각 투표 시간이 투표 기간 내에 포함되는지 확인 private void validateVoteWithinPeriod(TimeVotePeriod period, List dateTimes) { for (LocalDateTime dateTime : dateTimes) { - if (dateTime.isBefore(period.getStartDate()) || dateTime.isAfter(period.getEndDate())) { + if (dateTime.isBefore(period.getStartDate()) || dateTime.isAfter(period.getEndDate().with( + LocalTime.of(23, 59, 59)))) { throw new BusinessException(TIME_VOTE_OUT_OF_RANGE); } } diff --git a/src/main/java/grep/neogulcoder/domain/users/controller/UserController.java b/src/main/java/grep/neogulcoder/domain/users/controller/UserController.java index 5577dc86..9fd6c33f 100644 --- a/src/main/java/grep/neogulcoder/domain/users/controller/UserController.java +++ b/src/main/java/grep/neogulcoder/domain/users/controller/UserController.java @@ -3,13 +3,16 @@ import grep.neogulcoder.domain.users.controller.dto.request.PasswordRequest; import grep.neogulcoder.domain.users.controller.dto.request.SignUpRequest; import grep.neogulcoder.domain.users.controller.dto.request.UpdatePasswordRequest; +import grep.neogulcoder.domain.users.controller.dto.response.AllUserResponse; import grep.neogulcoder.domain.users.controller.dto.response.UserResponse; +import grep.neogulcoder.domain.users.entity.User; import grep.neogulcoder.domain.users.service.EmailVerificationService; import grep.neogulcoder.domain.users.service.UserService; import grep.neogulcoder.global.auth.Principal; import grep.neogulcoder.global.response.ApiResponse; import jakarta.validation.Valid; import java.io.IOException; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -47,11 +50,17 @@ public ApiResponse get(@PathVariable("userid") Long userId) { return ApiResponse.success(userResponse); } + @GetMapping("/all") + public ApiResponse> getAll() { + List users = usersService.getAllUsers(); + return ApiResponse.success(users); + } + @PutMapping(value = "/update/profile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse updateProfile( @AuthenticationPrincipal Principal principal, - @RequestPart("nickname") String nickname, - @RequestPart(value = "profileImage", required = false) MultipartFile profileImage + @RequestParam(value = "nickname", required = false) String nickname, + @RequestParam(value = "profileImage", required = false) MultipartFile profileImage ) throws IOException { usersService.updateProfile(principal.getUserId(), nickname, profileImage); return ApiResponse.noContent(); diff --git a/src/main/java/grep/neogulcoder/domain/users/controller/UserSpecification.java b/src/main/java/grep/neogulcoder/domain/users/controller/UserSpecification.java index f5305880..55b051ac 100644 --- a/src/main/java/grep/neogulcoder/domain/users/controller/UserSpecification.java +++ b/src/main/java/grep/neogulcoder/domain/users/controller/UserSpecification.java @@ -3,6 +3,7 @@ import grep.neogulcoder.domain.users.controller.dto.request.PasswordRequest; import grep.neogulcoder.domain.users.controller.dto.request.SignUpRequest; import grep.neogulcoder.domain.users.controller.dto.request.UpdatePasswordRequest; +import grep.neogulcoder.domain.users.controller.dto.response.AllUserResponse; import grep.neogulcoder.domain.users.controller.dto.response.UserResponse; import grep.neogulcoder.global.auth.Principal; import grep.neogulcoder.global.response.ApiResponse; @@ -11,6 +12,7 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.tags.Tag; import java.io.IOException; +import java.util.List; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; @@ -22,6 +24,9 @@ public interface UserSpecification { @Operation(summary = "회원 가입", description = "회원 정보를 저장합니다.") ApiResponse signUp(@RequestBody SignUpRequest request); + @Operation(summary = "회원 전체 조회", description = "회원 전체 정보를 조회합니다.") + ApiResponse> getAll(); + @Operation(summary = "회원 조회", description = "회원 정보를 조회합니다.") ApiResponse get(@AuthenticationPrincipal Principal principal); diff --git a/src/main/java/grep/neogulcoder/domain/users/controller/dto/response/AllUserResponse.java b/src/main/java/grep/neogulcoder/domain/users/controller/dto/response/AllUserResponse.java new file mode 100644 index 00000000..5b211abb --- /dev/null +++ b/src/main/java/grep/neogulcoder/domain/users/controller/dto/response/AllUserResponse.java @@ -0,0 +1,31 @@ +package grep.neogulcoder.domain.users.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +@Data +@Schema(description = "전체 사용자 정보 응답 DTO") +public class AllUserResponse { + + @Schema(description = "사용자 ID", example = "1") + private Long id; + + @Schema(description = "사용자 이메일", example = "user@example.com") + private String email; + + @Schema(description = "사용자 닉네임", example = "코딩천재") + private String nickname; + + @Schema(description = "프로필 이미지 URL", example = "https://cdn.example.com/profile/user1.png") + private String profileImageUrl; + + @Builder + private AllUserResponse(Long id, String email, String nickname, String profileImageUrl) { + this.id = id; + this.email = email; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + } + +} diff --git a/src/main/java/grep/neogulcoder/domain/users/exception/code/UserErrorCode.java b/src/main/java/grep/neogulcoder/domain/users/exception/code/UserErrorCode.java index c82370ce..bab6481a 100644 --- a/src/main/java/grep/neogulcoder/domain/users/exception/code/UserErrorCode.java +++ b/src/main/java/grep/neogulcoder/domain/users/exception/code/UserErrorCode.java @@ -7,7 +7,7 @@ @Getter public enum UserErrorCode implements ErrorCode { - USER_NOT_FOUND("U001",HttpStatus.BAD_REQUEST,"회원을 찾을 수 없습니다."), + USER_NOT_FOUND("U001",HttpStatus.NOT_FOUND,"회원을 찾을 수 없습니다."), PASSWORD_MISMATCH("U002", HttpStatus.BAD_REQUEST, "비밀번호를 다시 확인해주세요."), PASSWORD_UNCHECKED("U003", HttpStatus.BAD_REQUEST, "비밀번호와 비밀번호 확인이 다릅니다"), IS_DUPLICATED_MALI("U004", HttpStatus.BAD_REQUEST,"이미 존재하는 이메일입니다."), diff --git a/src/main/java/grep/neogulcoder/domain/users/repository/UserRepository.java b/src/main/java/grep/neogulcoder/domain/users/repository/UserRepository.java index 9cc2a2a3..4785cd8e 100644 --- a/src/main/java/grep/neogulcoder/domain/users/repository/UserRepository.java +++ b/src/main/java/grep/neogulcoder/domain/users/repository/UserRepository.java @@ -15,4 +15,5 @@ public interface UserRepository extends JpaRepository { Optional findByNickname(String nickname); Page findByEmailContainingIgnoreCase(String email, Pageable pageable); List findByIdIn(List userIds); + List findAllByActivatedTrue(); } diff --git a/src/main/java/grep/neogulcoder/domain/users/service/UserService.java b/src/main/java/grep/neogulcoder/domain/users/service/UserService.java index 4fb9fa28..008e28d5 100644 --- a/src/main/java/grep/neogulcoder/domain/users/service/UserService.java +++ b/src/main/java/grep/neogulcoder/domain/users/service/UserService.java @@ -9,6 +9,7 @@ import grep.neogulcoder.domain.prtemplate.service.PrTemplateService; import grep.neogulcoder.domain.study.service.StudyManagementService; import grep.neogulcoder.domain.users.controller.dto.request.SignUpRequest; +import grep.neogulcoder.domain.users.controller.dto.response.AllUserResponse; import grep.neogulcoder.domain.users.controller.dto.response.UserResponse; import grep.neogulcoder.domain.users.entity.User; import grep.neogulcoder.domain.users.exception.EmailDuplicationException; @@ -24,6 +25,7 @@ import grep.neogulcoder.global.utils.upload.uploader.LocalFileUploader; import jakarta.transaction.Transactional; import java.io.IOException; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.env.Environment; @@ -88,7 +90,6 @@ public void signUp(SignUpRequest request) { verificationService.clearVerifiedStatus(request.getEmail()); } - @Transactional public void updateProfile(Long userId, String nickname, MultipartFile profileImage) throws IOException { @@ -167,6 +168,18 @@ public UserResponse getUserResponse(Long userId) { user.getRole()); } + public List getAllUsers() { + return userRepository.findAllByActivatedTrue().stream() + .map(user -> AllUserResponse.builder() + .id(user.getId()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .profileImageUrl(user.getProfileImageUrl()) + .build() + ) + .toList(); + } + private User findUser(Long id) { return userRepository.findById(id) .orElseThrow( diff --git a/src/main/java/grep/neogulcoder/global/provider/InviteMessageProvider.java b/src/main/java/grep/neogulcoder/global/provider/InviteMessageProvider.java new file mode 100644 index 00000000..25db59b1 --- /dev/null +++ b/src/main/java/grep/neogulcoder/global/provider/InviteMessageProvider.java @@ -0,0 +1,36 @@ +package grep.neogulcoder.global.provider; + +import grep.neogulcoder.domain.alram.type.AlarmType; +import grep.neogulcoder.domain.alram.type.DomainType; +import grep.neogulcoder.domain.study.Study; +import grep.neogulcoder.domain.study.exception.code.StudyErrorCode; +import grep.neogulcoder.domain.study.repository.StudyRepository; +import grep.neogulcoder.global.exception.business.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class InviteMessageProvider implements MessageProvidable { + + private final StudyRepository studyRepository; + + @Override + public boolean isSupport(AlarmType alarmType) { + return alarmType == AlarmType.INVITE; + } + + @Override + public String provideMessage(DomainType domainType, Long domainId) { + if (domainType != DomainType.STUDY) { + throw new IllegalArgumentException("초대 알림은 스터디 도메인에만 해당됩니다."); + } + + String studyName = studyRepository.findById(domainId) + .map(Study::getName) + .orElseThrow(() -> new NotFoundException(StudyErrorCode.STUDY_NOT_FOUND)); + + return String.format("스터디: '%s' 에서 당신을 초대하고 싶어합니다.", studyName); + } + +} diff --git a/src/main/java/grep/neogulcoder/global/provider/MessageProvidable.java b/src/main/java/grep/neogulcoder/global/provider/MessageProvidable.java new file mode 100644 index 00000000..ece99f88 --- /dev/null +++ b/src/main/java/grep/neogulcoder/global/provider/MessageProvidable.java @@ -0,0 +1,11 @@ +package grep.neogulcoder.global.provider; + +import grep.neogulcoder.domain.alram.type.AlarmType; +import grep.neogulcoder.domain.alram.type.DomainType; + +public interface MessageProvidable { + + boolean isSupport(AlarmType alarmType); + + String provideMessage(DomainType domainType, Long domainId); +} diff --git a/src/main/java/grep/neogulcoder/global/provider/finder/MessageFinder.java b/src/main/java/grep/neogulcoder/global/provider/finder/MessageFinder.java new file mode 100644 index 00000000..b4e8bdc2 --- /dev/null +++ b/src/main/java/grep/neogulcoder/global/provider/finder/MessageFinder.java @@ -0,0 +1,24 @@ +package grep.neogulcoder.global.provider.finder; + +import grep.neogulcoder.domain.alram.type.AlarmType; +import grep.neogulcoder.domain.alram.type.DomainType; +import grep.neogulcoder.global.provider.MessageProvidable; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MessageFinder { + + private final List providers; + + public String findMessage(AlarmType alarmType, DomainType domainType, Long domainId) { + return providers.stream() + .filter(provider -> provider.isSupport(alarmType)) + .findFirst() + .map(provider -> provider.provideMessage(domainType, domainId)) + .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 알림 타입입니다.")); + } + +} diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index a54cff93..71f65cba 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -7,12 +7,12 @@ INSERT INTO member (activated, email, password, nickname, profile_image_url, rol INSERT INTO member (activated, email, password, nickname, profile_image_url, role, oauth_id, oauth_provider) VALUES (true,'test2@test.com', '{bcrypt}$2b$12$TK9zyc6f5qjHE1sSUSULieLOJVDBraWqSz2HRrIKgEKjUES0J2kva', 'test2','https://placekitten.com/812/625', 'ROLE_USER', NULL, NULL); INSERT INTO member (activated, email, password, nickname, profile_image_url, role, oauth_id, oauth_provider) VALUES (true,'test3@test.com', '{bcrypt}$2b$12$TK9zyc6f5qjHE1sSUSULieLOJVDBraWqSz2HRrIKgEKjUES0J2kva', 'test3','https://placekitten.com/812/626', 'ROLE_USER', NULL, NULL); -INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (NULL, '자바 스터디', 'IT', 10, 1, 'ONLINE', NULL, '2025-08-01', '2025-12-31', '자바 스터디에 오신 것을 환영합니다.', 'https://example.com/image.jpg', FALSE, TRUE, FALSE); -INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (NULL, '파이썬 스터디', 'IT', 8, 1, 'OFFLINE', '대구', '2025-09-01', '2026-01-31', '파이썬 기초부터 심화까지 학습합니다.', 'https://example.com/python.jpg', FALSE, TRUE, FALSE); -INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (NULL, '디자인 스터디', 'DESIGN', 6, 1, 'HYBRID', '서울', '2025-07-15', '2025-10-15', 'UI/UX 디자인 실습 중심 스터디입니다.', 'https://example.com/design.jpg', FALSE, TRUE, FALSE); -INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (NULL, '7급 공무원 스터디', 'EXAM', 12, 1, 'ONLINE', NULL, '2025-08-10', '2025-12-20', '7급 공무원 대비 스터디입니다.', 'https://example.com/exam.jpg', FALSE, TRUE, FALSE); -INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (NULL, '토익 스터디', 'LANGUAGE', 9, 1, 'OFFLINE', '광주', '2025-09-05', '2026-02-28', '토익 스터디입니다.', 'https://example.com/datascience.jpg', FALSE, TRUE, FALSE); -INSERT INTO study (origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (NULL, '최적시간 테스트 스터디', 'IT', 9, 1, 'OFFLINE', '광주', '2025-09-05', '2026-02-28', '테스트 스터디입니다.', 'https://example.com/datascience.jpg', FALSE, TRUE, FALSE); +INSERT INTO study (user_id, origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (1, NULL, '자바 스터디', 'IT', 10, 1, 'ONLINE', NULL, '2025-08-01', '2025-12-31', '자바 스터디에 오신 것을 환영합니다.', 'https://example.com/image.jpg', FALSE, TRUE, FALSE); +INSERT INTO study (user_id, origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (1, NULL, '파이썬 스터디', 'IT', 8, 1, 'OFFLINE', '대구', '2025-09-01', '2026-01-31', '파이썬 기초부터 심화까지 학습합니다.', 'https://example.com/python.jpg', FALSE, TRUE, FALSE); +INSERT INTO study (user_id, origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (1, NULL, '디자인 스터디', 'DESIGN', 6, 1, 'HYBRID', '서울', '2025-07-15', '2025-10-15', 'UI/UX 디자인 실습 중심 스터디입니다.', 'https://example.com/design.jpg', FALSE, TRUE, FALSE); +INSERT INTO study (user_id, origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (1, NULL, '7급 공무원 스터디', 'EXAM', 12, 1, 'ONLINE', NULL, '2025-08-10', '2025-12-20', '7급 공무원 대비 스터디입니다.', 'https://example.com/exam.jpg', FALSE, TRUE, FALSE); +INSERT INTO study (user_id, origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (1, NULL, '토익 스터디', 'LANGUAGE', 9, 1, 'OFFLINE', '광주', '2025-09-05', '2026-02-28', '토익 스터디입니다.', 'https://example.com/datascience.jpg', FALSE, TRUE, FALSE); +INSERT INTO study (user_id, origin_study_id, name, category, capacity, current_count, study_type, location, start_date, end_date, introduction, image_url, extended, activated, finished) VALUES (1, NULL, '최적시간 테스트 스터디', 'IT', 9, 1, 'OFFLINE', '광주', '2025-09-05', '2026-02-28', '테스트 스터디입니다.', 'https://example.com/datascience.jpg', FALSE, TRUE, FALSE); INSERT INTO study_post (study_id, user_id, title, category, content, activated) VALUES (5, 3, '자바 스터디 1주차 공지.', 'NOTICE', '1주차 스터디 내용은 가위바위보 게임 만들기 입니다. 모두 각자 만드시고 설명 하는 시간을 가지겠습니다.', true); INSERT INTO study_post (study_id, user_id, title, category, content, activated) VALUES (4, 4, '익명 클래스 자료 공유', 'FREE', '동물 이라는 인터페이스가 있을때 구현체는 강아지, 고양이 등이 있습니다. 구현을 하면 여러 구현 클래스가 필요합니다 이를 줄이기 위해 익명클래스를 사용할 수 있습니다.', true); @@ -32,7 +32,7 @@ INSERT INTO study_member (study_id, user_id, role, participated, activated) VALU INSERT INTO study_member (study_id, user_id, role, participated, activated) VALUES (3, 2, 'LEADER', FALSE, TRUE); INSERT INTO study_member (study_id, user_id, role, participated, activated) VALUES (3, 1, 'MEMBER', FALSE, TRUE); INSERT INTO study_member (study_id, user_id, role, participated, activated) VALUES (6, 7, 'LEADER', FALSE, TRUE); -INSERT INTO study_member (study_id, user_id, role, participated, activated) VALUES (6, 8, 'MEMBER', FALSE, TRUE); +INSERT INTO study_member (study_id, user_id, role, participated, activated) VALUES (5, 8, 'MEMBER', FALSE, TRUE); INSERT INTO study_member (study_id, user_id, role, participated, activated) VALUES (6, 9, 'MEMBER', FALSE, TRUE); INSERT INTO recruitment_post (user_id, study_id, subject, content, recruitment_count, expired_date, status, activated) VALUES (3, 1, '자바 스터디 모집', '이펙티브 자바 공부하실분 구해요!!', 3, '2025-04-19', 'COMPLETE', true); @@ -65,7 +65,7 @@ INSERT INTO team_calendar (study_id, user_id, calendar_id, activated) VALUES (1, INSERT INTO team_calendar (study_id, user_id, calendar_id, activated) VALUES (3, 3, 4, TRUE); INSERT INTO team_calendar (study_id, user_id, calendar_id, activated) VALUES (4, 4, 5, TRUE); -INSERT INTO time_vote_period (study_id, start_date, end_date, activated) VALUES (6, '2025-07-25 00:00:00', '2025-07-30 00:00:00', TRUE); +INSERT INTO time_vote_period (study_id, start_date, end_date, activated) VALUES (6, '2025-07-25 00:00:00', '2025-07-30 23:59:59', TRUE); -- 2025-07-25 10:00:00 - 2명 INSERT INTO time_vote (period_id, study_member_id, time_slot, activated) VALUES (1, 6, '2025-07-25 10:00:00', TRUE); @@ -156,7 +156,7 @@ INSERT INTO attendance (study_id, user_id, attendance_date, activated) VALUES (2 INSERT INTO attendance (study_id, user_id, attendance_date, activated) VALUES (3, 2, '2025-07-03', TRUE); INSERT INTO group_chat_room (study_id, activated) VALUES (1, true); -INSERT INTO group_chat_room (study_id, activated) VALUES (2, true); +INSERT INTO group_chat_room (study_id, activated) VALUES (6, true); INSERT INTO group_chat_message (room_id, user_id, message, activated) VALUES (1, 1, '안녕하세요! 스터디 언제 시작하나요?', TRUE); INSERT INTO group_chat_message (room_id, user_id, message, activated) VALUES (1, 2, '오늘 저녁 8시에 시작해요!', TRUE); diff --git a/src/main/resources/static/Chat-Test.html b/src/main/resources/static/Chat-Test.html index edb6eaf0..4a745752 100644 --- a/src/main/resources/static/Chat-Test.html +++ b/src/main/resources/static/Chat-Test.html @@ -10,8 +10,8 @@

그룹 채팅 테스트 (로컬 서버)

- - + + @@ -30,11 +30,11 @@

채팅 로그