diff --git a/build.gradle b/build.gradle index 6f74c2eb..02b3789e 100644 --- a/build.gradle +++ b/build.gradle @@ -86,9 +86,11 @@ dependencies { // 배포 관련 의존성 // runtimeOnly 'org.postgresql:postgresql' implementation 'org.springframework.boot:spring-boot-devtools' - implementation 'com.google.cloud:spring-cloud-gcp-starter-secretmanager:4.9.1' implementation 'com.google.cloud:google-cloud-storage:2.38.0' + + // WebSocket + STOMP 통신용 + implementation 'org.springframework.boot:spring-boot-starter-websocket' } tasks.named('test') { diff --git a/src/main/java/grep/neogul_coder/domain/attendance/Attendance.java b/src/main/java/grep/neogul_coder/domain/attendance/Attendance.java index 0ea9ef53..9b56c225 100644 --- a/src/main/java/grep/neogul_coder/domain/attendance/Attendance.java +++ b/src/main/java/grep/neogul_coder/domain/attendance/Attendance.java @@ -2,15 +2,18 @@ import grep.neogul_coder.global.entity.BaseEntity; import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; -import java.time.LocalDate; +import java.time.LocalDateTime; +@Getter @Entity public class Attendance extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long attendanceId; + private Long id; @Column(nullable = false) private Long studyId; @@ -19,5 +22,22 @@ public class Attendance extends BaseEntity { private Long userId; @Column(nullable = false) - private LocalDate attendanceDate; + private LocalDateTime attendanceDate; + + protected Attendance() {} + + @Builder + private Attendance(Long studyId, Long userId, LocalDateTime attendanceDate) { + this.studyId = studyId; + this.userId = userId; + this.attendanceDate = attendanceDate; + } + + public static Attendance create(Long studyId, Long userId) { + return Attendance.builder() + .studyId(studyId) + .userId(userId) + .attendanceDate(LocalDateTime.now()) + .build(); + } } diff --git a/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceController.java b/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceController.java index b4293f13..3994a76e 100644 --- a/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceController.java +++ b/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceController.java @@ -1,25 +1,32 @@ package grep.neogul_coder.domain.attendance.controller; -import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceResponse; +import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceInfoResponse; +import grep.neogul_coder.domain.attendance.service.AttendanceService; +import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; -import java.util.List; - -@RequestMapping("/api/attendances") +@RequestMapping("/api/studies/{studyId}/attendances") +@RequiredArgsConstructor @RestController public class AttendanceController implements AttendanceSpecification { + private final AttendanceService attendanceService; + @GetMapping - public ApiResponse> getAttendances() { - return ApiResponse.success(List.of(new AttendanceResponse())); + public ApiResponse getAttendances(@PathVariable("studyId") Long studyId, + @AuthenticationPrincipal Principal userDetails) { + AttendanceInfoResponse attendances = attendanceService.getAttendances(studyId, userDetails.getUserId()); + return ApiResponse.success(attendances); } @PostMapping - public ApiResponse createAttendance() { - return ApiResponse.noContent(); + public ApiResponse createAttendance(@PathVariable("studyId") Long studyId, + @AuthenticationPrincipal Principal userDetails) { + Long userId = userDetails.getUserId(); + Long id = attendanceService.createAttendance(studyId, userId); + return ApiResponse.success(id); } } diff --git a/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceSpecification.java b/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceSpecification.java index c3ae8439..41144e1f 100644 --- a/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/attendance/controller/AttendanceSpecification.java @@ -1,6 +1,8 @@ package grep.neogul_coder.domain.attendance.controller; +import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceInfoResponse; import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceResponse; +import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -11,8 +13,8 @@ public interface AttendanceSpecification { @Operation(summary = "출석 조회", description = "일주일 단위로 출석을 조회합니다.") - ApiResponse> getAttendances(); + ApiResponse getAttendances(Long studyId, Principal userDetails); @Operation(summary = "출석 체크", description = "스터디에 출석을 합니다.") - ApiResponse createAttendance(); + ApiResponse createAttendance(Long studyId, Principal userDetails); } diff --git a/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceInfoResponse.java b/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceInfoResponse.java new file mode 100644 index 00000000..fb9f3838 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceInfoResponse.java @@ -0,0 +1,30 @@ +package grep.neogul_coder.domain.attendance.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +public class AttendanceInfoResponse { + + @Schema(description = "출석일 목록") + private List attendances; + + @Schema(description = "출석률", example = "50") + private int attendanceRate; + + @Builder + private AttendanceInfoResponse(List attendances, int attendanceRate) { + this.attendances = attendances; + this.attendanceRate = attendanceRate; + } + + public static AttendanceInfoResponse of(List responses, int attendanceRate) { + return AttendanceInfoResponse.builder() + .attendances(responses) + .attendanceRate(attendanceRate) + .build(); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceResponse.java b/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceResponse.java index 38e818bf..e43f5fbd 100644 --- a/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceResponse.java +++ b/src/main/java/grep/neogul_coder/domain/attendance/controller/dto/response/AttendanceResponse.java @@ -1,6 +1,8 @@ package grep.neogul_coder.domain.attendance.controller.dto.response; +import grep.neogul_coder.domain.attendance.Attendance; import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; import lombok.Getter; import java.time.LocalDate; @@ -16,4 +18,19 @@ public class AttendanceResponse { @Schema(description = "출석일", example = "2025-07-10") private LocalDate attendanceDate; + + @Builder + private AttendanceResponse(Long studyId, Long userId, LocalDate attendanceDate) { + this.studyId = studyId; + this.userId = userId; + this.attendanceDate = attendanceDate; + } + + public static AttendanceResponse from(Attendance attendance) { + return AttendanceResponse.builder() + .studyId(attendance.getStudyId()) + .userId(attendance.getUserId()) + .attendanceDate(attendance.getAttendanceDate().toLocalDate()) + .build(); + } } diff --git a/src/main/java/grep/neogul_coder/domain/attendance/exception/code/AttendanceErrorCode.java b/src/main/java/grep/neogul_coder/domain/attendance/exception/code/AttendanceErrorCode.java new file mode 100644 index 00000000..69500110 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/attendance/exception/code/AttendanceErrorCode.java @@ -0,0 +1,21 @@ +package grep.neogul_coder.domain.attendance.exception.code; + +import grep.neogul_coder.global.response.code.ErrorCode; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum AttendanceErrorCode implements ErrorCode { + + ATTENDANCE_ALREADY_CHECKED("A001", HttpStatus.BAD_REQUEST, "출석은 하루에 한 번만 가능합니다."); + + private final String code; + private final HttpStatus status; + private final String message; + + AttendanceErrorCode(String code, HttpStatus status, String message) { + this.code = code; + this.status = status; + this.message = message; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/attendance/repository/AttendanceRepository.java b/src/main/java/grep/neogul_coder/domain/attendance/repository/AttendanceRepository.java new file mode 100644 index 00000000..a199c0ce --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/attendance/repository/AttendanceRepository.java @@ -0,0 +1,19 @@ +package grep.neogul_coder.domain.attendance.repository; + +import grep.neogul_coder.domain.attendance.Attendance; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface AttendanceRepository extends JpaRepository { + @Query("select count(a) > 0 from Attendance a where a.studyId = :studyId and a.userId = :userId and a.attendanceDate between :startOfDay and :endOfDay") + boolean existsTodayAttendance(@Param("studyId") Long studyId, + @Param("userId") Long userId, + @Param("startOfDay") LocalDateTime startOfDay, + @Param("endOfDay") LocalDateTime endOfDay); + + List findByStudyIdAndUserId(Long studyId, Long userId); +} diff --git a/src/main/java/grep/neogul_coder/domain/attendance/service/AttendanceService.java b/src/main/java/grep/neogul_coder/domain/attendance/service/AttendanceService.java new file mode 100644 index 00000000..1738dad5 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/attendance/service/AttendanceService.java @@ -0,0 +1,81 @@ +package grep.neogul_coder.domain.attendance.service; + +import grep.neogul_coder.domain.attendance.Attendance; +import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceInfoResponse; +import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceResponse; +import grep.neogul_coder.domain.attendance.repository.AttendanceRepository; +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.repository.StudyMemberRepository; +import grep.neogul_coder.domain.study.repository.StudyRepository; +import grep.neogul_coder.global.exception.business.BusinessException; +import grep.neogul_coder.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.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static grep.neogul_coder.domain.attendance.exception.code.AttendanceErrorCode.ATTENDANCE_ALREADY_CHECKED; +import static grep.neogul_coder.domain.study.exception.code.StudyErrorCode.STUDY_NOT_FOUND; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class AttendanceService { + + private final AttendanceRepository attendanceRepository; + private final StudyRepository studyRepository; + private final StudyMemberRepository studyMemberRepository; + + public AttendanceInfoResponse getAttendances(Long studyId, Long userId) { + Study study = studyRepository.findByIdAndActivatedTrue(studyId) + .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); + + List attendances = attendanceRepository.findByStudyIdAndUserId(studyId, userId); + List responses = attendances.stream() + .map(AttendanceResponse::from) + .toList(); + + int attendanceRate = getAttendanceRate(studyId, userId, study, responses); + + return AttendanceInfoResponse.of(responses, attendanceRate); + } + + @Transactional + public Long createAttendance(Long studyId, Long userId) { + Study study = studyRepository.findByIdAndActivatedTrue(studyId) + .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); + + validateNotAlreadyChecked(studyId, userId); + + Attendance attendance = Attendance.create(studyId, userId); + attendanceRepository.save(attendance); + return attendance.getId(); + } + + private int getAttendanceRate(Long studyId, Long userId, Study study, List responses) { + LocalDate start = study.getStartDate().toLocalDate(); + LocalDate participated = studyMemberRepository.findCreatedDateByStudyIdAndUserId(studyId, userId).toLocalDate(); + LocalDate attendanceStart = start.isAfter(participated) ? start : participated; + LocalDate end = study.getEndDate().toLocalDate(); + + int totalDays = (int) ChronoUnit.DAYS.between(attendanceStart, end) + 1; + int attendDays = responses.size(); + int attendanceRate = totalDays == 0 ? 0 : Math.round(((float) attendDays / totalDays) * 100); + + return attendanceRate; + } + + private void validateNotAlreadyChecked(Long studyId, Long userId) { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX); + + if (attendanceRepository.existsTodayAttendance(studyId, userId, startOfDay, endOfDay)) { + throw new BusinessException(ATTENDANCE_ALREADY_CHECKED); + } + } +} diff --git a/src/main/java/grep/neogul_coder/domain/comment/Comment.java b/src/main/java/grep/neogul_coder/domain/comment/Comment.java deleted file mode 100644 index a355cf2b..00000000 --- a/src/main/java/grep/neogul_coder/domain/comment/Comment.java +++ /dev/null @@ -1,26 +0,0 @@ -package grep.neogul_coder.domain.comment; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.validation.constraints.NotBlank; - -@Entity -public class Comment { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private Long postId; - - @Column(nullable = false) - private Long userId; - - @Column(nullable = false, length = 100) - @NotBlank(message = "내용은 필수입니다.") - private String content; -} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestController.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestController.java new file mode 100644 index 00000000..32efb300 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestController.java @@ -0,0 +1,31 @@ +package grep.neogul_coder.domain.groupchat.controller; + +import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatMessageResponseDto; +import grep.neogul_coder.domain.groupchat.service.GroupChatService; +import grep.neogul_coder.global.response.ApiResponse; +import grep.neogul_coder.global.response.PageResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/chat") +public class GroupChatRestController implements GroupChatRestSpecification { + + private final GroupChatService groupChatService; + + // 과거 채팅 메시지 페이징 조회 (무한 스크롤용) + @Override + @GetMapping("/room/{roomId}/messages") + public ApiResponse> getMessages( + @PathVariable("roomId") Long roomId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + // 서비스에서 페이징된 메시지 조회 + PageResponse pageResponse = + groupChatService.getMessages(roomId, page, size); + + return ApiResponse.success(pageResponse); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java new file mode 100644 index 00000000..4ec795df --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatRestSpecification.java @@ -0,0 +1,86 @@ +package grep.neogul_coder.domain.groupchat.controller; + +import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatMessageResponseDto; +import grep.neogul_coder.global.response.ApiResponse; +import grep.neogul_coder.global.response.PageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "GroupChat", description = "채팅 메시지 조회 API (무한 스크롤용)") +public interface GroupChatRestSpecification { + + @Operation( + summary = "채팅 메시지 페이징 조회", + description = """ + 이 API는 **채팅방의 과거 메시지**를 페이지 단위로 가져오는 용도입니다. + WebSocket의 실시간 수신(`/sub/chat/room/{roomId}`)과는 별개로, + 채팅방에 입장할 때 이전 대화 기록을 불러오는 데 사용됩니다. + + --- + + **프론트엔드 연동 흐름 (권장 방식)**: + 1. **채팅방 입장 시** → `GET /api/chat/room/{roomId}/messages?page=0&size=20` 호출해 최신 메시지 20개 로드 + 2. **스크롤 위로 올릴 때** → `page=1`, `page=2` ... 순차적으로 과거 메시지를 추가 로딩 (무한 스크롤) + 3. **동시에** → WebSocket(`wss://wibby.cedartodo.uk/ws-stomp`) 연결 후 `/sub/chat/room/{roomId}`를 **구독**해 실시간 메시지 수신 + + --- + + **파라미터 설명**: + - `roomId`: 채팅방 ID + - `page`: 페이지 번호 (0부터 시작, 0 = 최신 메시지 20개) + - `size`: 한 페이지당 메시지 수 (기본값 20) + - 메시지는 **오래된 순(오름차순)**으로 반환됩니다. + + --- + + **응답 구조**: + - `ApiResponse>` 형태 + - `content()`로 메시지 목록 접근 가능 + - 페이지네이션 정보: `currentNumber()`, `prevPage()`, `nextPage()` 등 + + --- + + **예시 요청 URL**: + ``` + /api/chat/room/1/messages?page=0&size=20 + ``` + + **예시 응답**: + ```json + { + "success": true, + "data": { + "content": [ + { + "id": 101, + "roomId": 1, + "senderId": 10, + "senderNickname": "유강현", + "profileImageUrl": "https://example.com/profile.jpg", + "message": "안녕하세요!", + "sentAt": "2025-07-21T14:32:00" + } + ], + "currentNumber": 0, + "nextPage": 1, + "prevPage": null + } + } + ``` + """ + ) + + ApiResponse> getMessages( + @Parameter(description = "채팅방 ID", example = "1") + @PathVariable("roomId") Long roomId, + + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + @RequestParam(defaultValue = "0") int page, + + @Parameter(description = "한 페이지당 메시지 수", example = "20") + @RequestParam(defaultValue = "20") int size + ); +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java index f668694d..7b8adaf0 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatSwaggerSpecification.java @@ -8,12 +8,61 @@ import java.util.List; -@Tag(name = "GroupChat-Swagger", description = "WebSocket 구조 설명용 Swagger 문서") +@Tag(name = "GroupChat", description = "WebSocket 구조 설명용 Swagger 문서") public interface GroupChatSwaggerSpecification { - @Operation(summary = "Pub 채팅 메시지 전송 구조 예시", description = "실제 채팅 전송은 WebSocket을 통해 `/pub/chat/message`로 전송됩니다.") + @Operation(summary = "채팅 메시지 전송 (WebSocket Pub)", + description = """ + **실제 채팅 메시지 전송은 WebSocket 연결 후에 이루어집니다.** + + **1. WebSocket 연결** + - 먼저 `wss://wibby.cedartodo.uk/ws-stomp` 엔드포인트로 STOMP 연결을 맺습니다. + + **2. 메시지 전송** + - 연결이 완료된 후 `/pub/chat/message` 경로로 메시지를 보냅니다. + + **예시 Request JSON** + ```json + { + "roomId": 1, + "senderId": 10, + "message": "안녕하세요!" + } + ``` + + ** Swagger에서 이 API를 실행해도 실제 전송은 되지 않으며, + WebSocket 통신 구조를 이해하기 위한 문서 예시입니다.** + """) ApiResponse sendMessage(GroupChatSwaggerRequest request); - @Operation(summary = "Sub 채팅 메시지 수신 구조 예시", description = "실제 메시지 수신은 WebSocket을 통해 `/sub/chat/room/{roomId}`로 수신됩니다.") + @Operation(summary = "채팅 메시지 수신 (WebSocket Sub)", + description = """ + **실제 메시지 수신 또한 WebSocket 연결이 필수입니다.** + + **1. WebSocket 연결** + - 먼저 `wss://wibby.cedartodo.uk/ws-stomp` 엔드포인트로 STOMP 연결을 맺습니다. + + **2. 메시지 구독** + - 연결 후 `/sub/chat/room/{roomId}` 경로를 구독(subscribe)하면 해당 채팅방의 새로운 메시지를 실시간으로 수신할 수 있습니다. + + **예시 Subscribe 경로** + `/sub/chat/room/1` + + **예시 수신 데이터(JSON)** + ```json + { + "id": 101, + "roomId": 1, + "senderId": 10, + "senderNickname": "유강현", + "profileImageUrl": "https://example.com/profile.jpg", + "message": "안녕하세요!", + "sentAt": "2025-07-21T14:32:00" + } + ``` + + ** Swagger에서는 WebSocket 구독을 테스트할 수 없으며, + 이 문서는 프론트엔드 구현 참고용입니다.** + """) ApiResponse> getMessages(Long roomId); } diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java new file mode 100644 index 00000000..c7feb85c --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/GroupChatWebSocketController.java @@ -0,0 +1,40 @@ +package grep.neogul_coder.domain.groupchat.controller; + +import grep.neogul_coder.domain.groupchat.controller.dto.requset.GroupChatMessageRequestDto; +import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatMessageResponseDto; +import grep.neogul_coder.domain.groupchat.service.GroupChatService; +import io.swagger.v3.oas.annotations.Hidden; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +// Swagger 문서에 노출되지 않도록 설정 +@Hidden +@Controller +public class GroupChatWebSocketController { + + private final GroupChatService groupChatService; + private final SimpMessagingTemplate messagingTemplate; + + // 생성자 주입을 통해 필요한 서비스와 템플릿 객체 주입 + public GroupChatWebSocketController(GroupChatService groupChatService, + SimpMessagingTemplate messagingTemplate) { + this.groupChatService = groupChatService; + this.messagingTemplate = messagingTemplate; + } + + // 클라이언트가 /pub/chat/message 로 보낼 때 처리됨 + @MessageMapping("/chat/message") + public void handleMessage(GroupChatMessageRequestDto requestDto) { + // 메시지를 DB에 저장하고, 응답 DTO 생성 + GroupChatMessageResponseDto responseDto = groupChatService.saveMessage(requestDto); + + // 구독 중인 클라이언트에게 메시지 전송 (채팅방 구분) + // 클라이언트는 /sub/chat/room/{roomId} 구독 중이어야 실시간으로 수신 가능 + messagingTemplate.convertAndSend( + "/sub/chat/room/" + requestDto.getRoomId(), // 메시지를 받을 대상 + responseDto // 클라이언트에 전달할 응답 메시지 DTO + ); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java index 41d589e2..a2bdabfc 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/requset/GroupChatMessageRequestDto.java @@ -1,39 +1,32 @@ -//package grep.neogul_coder.domain.groupchat.controller.dto; -// -//public class GroupChatMessageRequestDto { -// private Long roomId; -// private Long senderId; -// private String message; -// -// public GroupChatMessageRequestDto() {} -// -// public GroupChatMessageRequestDto(Long roomId, Long senderId, String message) { -// this.roomId = roomId; -// this.senderId = senderId; -// this.message = message; -// } -// -// public Long getRoomId() { -// return roomId; -// } -// -// public void setRoomId(Long roomId) { -// this.roomId = roomId; -// } -// -// public Long getSenderId() { -// return senderId; -// } -// -// public void setSenderId(Long senderId) { -// this.senderId = senderId; -// } -// -// public String getMessage() { -// return message; -// } -// -// public void setMessage(String message) { -// this.message = message; -// } -//} +package grep.neogul_coder.domain.groupchat.controller.dto.requset; + +import io.swagger.v3.oas.annotations.Hidden; +import lombok.Getter; + +@Hidden +@Getter +public class GroupChatMessageRequestDto { + private Long roomId; + private Long senderId; + private String message; + + public GroupChatMessageRequestDto() {} + + public GroupChatMessageRequestDto(Long roomId, Long senderId, String message) { + this.roomId = roomId; + this.senderId = senderId; + this.message = message; + } + + public void setRoomId(Long roomId) { + this.roomId = roomId; + } + + public void setSenderId(Long senderId) { + this.senderId = senderId; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java index 6414acb0..35e8a843 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatMessageResponseDto.java @@ -1,84 +1,61 @@ -//package grep.neogul_coder.domain.groupchat.controller.dto; -// -//import java.time.LocalDateTime; -// -//public class GroupChatMessageResponseDto { -// -// private Long id; // 메시지 고유 ID -// private Long roomId; // (선택) 프론트 팀에서 필요시 -// private Long senderId; // user 추적 -// private String content; // 메시지 내용 -// private String senderName; // 메시지 보낸 사람(닉네임) -// private String senderProfileUrl; // 메시지 보낸 사람의 프로필 사진 URL -// private LocalDateTime sentAt; // 메시지 보낸 시각 -// -// public GroupChatMessageResponseDto() { -// } -// -// public GroupChatMessageResponseDto(Long id, Long roomId, Long senderId, String content, -// String senderName,String senderProfileUrl, LocalDateTime sentAt) { -// this.id = id; -// this.roomId = roomId; -// this.senderId = senderId; -// this.content = content; -// this.senderName = senderName; -// this.senderProfileUrl = senderProfileUrl; -// this.sentAt = sentAt; -// } -// -// public Long getId() { -// return id; -// } -// -// public void setId(Long id) { -// this.id = id; -// } -// -// public Long getRoomId() { -// return roomId; -// } -// -// public void setRoomId(Long roomId) { -// this.roomId = roomId; -// } -// -// public Long getSenderId() { -// return senderId; -// } -// -// public void setSenderId(Long senderId) { -// this.senderId = senderId; -// } -// -// public String getContent() { -// return content; -// } -// -// public void setContent(String content) { -// this.content = content; -// } -// -// public String getSenderName() { -// return senderName; -// } -// -// public void setSenderName(String senderName) { -// this.senderName = senderName; -// } -// -// public String getSenderProfileUrl() { -// return senderProfileUrl; -// } -// -// public void setSenderProfileUrl(String senderProfileUrl) { -// this.senderProfileUrl = senderProfileUrl; -// } -// -// public LocalDateTime getSentAt() { -// return sentAt; -// } -// -// public void setSentAt(LocalDateTime sentAt) { -// this.sentAt = sentAt; -// } -//} +package grep.neogul_coder.domain.groupchat.controller.dto.response; + +import io.swagger.v3.oas.annotations.Hidden; +import java.time.LocalDateTime; +import lombok.Getter; + +@Hidden +@Getter +public class GroupChatMessageResponseDto { + + private Long id; // 메시지 고유 ID + private Long roomId; // 채팅방 ID + private Long senderId; // 보낸 사람 ID + private String senderNickname; // 보낸 사람 닉네임 + private String profileImageUrl; // 프로필 이미지 URL + private String message; // 메시지 내용 + private LocalDateTime sentAt; // 보낸 시간 + + public GroupChatMessageResponseDto() { + } + + public GroupChatMessageResponseDto(Long id, Long roomId, Long senderId, + String senderNickname, String profileImageUrl, + String message, LocalDateTime sentAt) { + this.id = id; + this.roomId = roomId; + this.senderId = senderId; + this.senderNickname = senderNickname; + this.profileImageUrl = profileImageUrl; + this.message = message; + this.sentAt = sentAt; + } + + public void setId(Long id) { + this.id = id; + } + + public void setRoomId(Long roomId) { + this.roomId = roomId; + } + + public void setSenderId(Long senderId) { + this.senderId = senderId; + } + + public void setSenderNickname(String senderNickname) { + this.senderNickname = senderNickname; + } + + public void setProfileImageUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + } + + public void setMessage(String message) { + this.message = message; + } + + public void setSentAt(LocalDateTime sentAt) { + this.sentAt = sentAt; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java index 845160ab..b4fadd44 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/controller/dto/response/GroupChatSwaggerResponse.java @@ -29,6 +29,4 @@ public class GroupChatSwaggerResponse { @Schema(description = "보낸 시간", example = "2025-07-07T17:45:00") private LocalDateTime sentAt; - - } diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java b/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java index 44140ec0..10ba941e 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/entity/GroupChatMessage.java @@ -3,6 +3,8 @@ import grep.neogul_coder.domain.users.entity.User; import grep.neogul_coder.global.entity.BaseEntity; import jakarta.persistence.*; +import java.time.LocalDateTime; +import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; @@ -21,5 +23,27 @@ public class GroupChatMessage extends BaseEntity { @Column(name = "user_id") private Long userId; - private String content; + private String message; + + private LocalDateTime sentAt; + + public void setMessageId(Long messageId) { + this.messageId = messageId; + } + + public void setGroupChatRoom(GroupChatRoom groupChatRoom) { + this.groupChatRoom = groupChatRoom; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public void setMessage(String message) { + this.message = message; + } + + public void setSentAt(LocalDateTime sentAt) { + this.sentAt = sentAt; + } } diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java index e68a8673..c5437d47 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatMessageRepository.java @@ -1,8 +1,19 @@ -//package grep.neogul_coder.domain.groupchat.repository; -// -//import grep.neogul_coder.domain.groupchat.domain.GroupChatMessage; -//import org.springframework.data.jpa.repository.JpaRepository; -// -//public interface GroupChatMessageRepository extends JpaRepository { -// -//} +package grep.neogul_coder.domain.groupchat.repository; + +import grep.neogul_coder.domain.groupchat.entity.GroupChatMessage; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface GroupChatMessageRepository extends JpaRepository { + + + // 채팅방(roomId)에 속한 메시지를 전송 시간 내림차순으로 페이징 조회 + @Query("SELECT m FROM GroupChatMessage m " + + "WHERE m.groupChatRoom.roomId = :roomId " + + "ORDER BY m.sentAt ASC") + Page findMessagesByRoomIdAsc(@Param("roomId") Long roomId, Pageable pageable); + +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatRoomRepository.java b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatRoomRepository.java index 7fa8618f..14127a4b 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatRoomRepository.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/repository/GroupChatRoomRepository.java @@ -1,18 +1,18 @@ -//package grep.neogul_coder.domain.groupchat.repository; -// -//import grep.neogul_coder.domain.groupchat.domain.GroupChatRoom; -//import java.util.Optional; -//import org.springframework.data.jpa.repository.JpaRepository; -// -//// JpaRepository : 기본적인 CRUD 자동제공 -//public interface GroupChatRoomRepository extends JpaRepository { -// -// // 스터디 ID로 채팅방을 찾는 메서드 -// // 스터디는 1개당 채팅방 1개가 연결되어 있으므로, -// // 이 메서드는 service 단에서 studyId를 기반으로 채팅방을 찾아올 때 사용됨! -// -// -// // Optional을 쓰는 이유 : studyId에 해당하는 채팅방이 없을 수 도 있기 때문 -// // Optional.empty()로 감싸 null 에러 방지 -// Optional findByStudy(Long studyId); -//} +package grep.neogul_coder.domain.groupchat.repository; + +import grep.neogul_coder.domain.groupchat.entity.GroupChatRoom; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +// JpaRepository : 기본적인 CRUD 자동제공 +public interface GroupChatRoomRepository extends JpaRepository { + + // 스터디 ID로 채팅방을 찾는 메서드 + // 스터디는 1개당 채팅방 1개가 연결되어 있으므로, + // 이 메서드는 service 단에서 studyId를 기반으로 채팅방을 찾아올 때 사용됨! + + + // Optional을 쓰는 이유 : studyId에 해당하는 채팅방이 없을 수 도 있기 때문 + // Optional.empty()로 감싸 null 에러 방지 + Optional findByStudyId(Long studyId); +} diff --git a/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java b/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java index 615f4ce0..ddce78be 100644 --- a/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java +++ b/src/main/java/grep/neogul_coder/domain/groupchat/service/GroupChatService.java @@ -1,54 +1,109 @@ -//package grep.neogul_coder.domain.groupchat.service; -// -//import grep.neogul_coder.domain.groupchat.domain.GroupChatMessage; -//import grep.neogul_coder.domain.groupchat.domain.GroupChatRoom; -//import grep.neogul_coder.domain.groupchat.dto.GroupChatMessageRequestDto; -//import grep.neogul_coder.domain.groupchat.dto.GroupChatMessageResponseDto; -//import grep.neogul_coder.domain.groupchat.repository.GroupChatMessageRepository; -//import grep.neogul_coder.domain.groupchat.repository.GroupChatRoomRepository; -//import grep.neogul_coder.domain.user.domain.User; -//import grep.neogul_coder.domain.user.repository.UserRepository; -//import org.springframework.stereotype.Service; -//import java.time.LocalDateTime; -// -//@Service -//public class GroupChatService { -// -// private final GroupChatMessageRepository messageRepository; -// private final GroupChatRoomRepository roomRepository; -// private final UserRepository userRepository; -// -// public GroupChatService(GroupChatMessageRepository messageRepository, -// GroupChatRoomRepository roomRepository, -// UserRepository userRepository) { -// this.messageRepository = messageRepository; -// this.roomRepository = roomRepository; -// this.userRepository = userRepository; -// } -// -// public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto requestDto) { -// // 필요한 도메인 객체 조회 -// GroupChatRoom room = roomRepository.findById(requestDto.getRoomId()) -// .orElseThrow(() -> new IllegalArgumentException("채팅방이 존재하지 않습니다.")); -// User sender = userRepository.findById(requestDto.getSenderId()) -// .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); -// -// // 메시지 생성 및 저장 -// GroupChatMessage message = new GroupChatMessage(); -// message.setRoom(room); -// message.setSender(sender); -// message.setContent(requestDto.getContent()); -// message.setSentAt(LocalDateTime.now()); -// -// messageRepository.save(message); -// -// // 응답 DTO 구성 -// return new GroupChatMessageResponseDto( -// message.getMessageId(), -// message.getContent(), -// sender.getNickname(), -// sender.getProfileImageUrl(), // 프론트에서 필요 -// message.getSentAt() -// ); -// } -//} +package grep.neogul_coder.domain.groupchat.service; + +import grep.neogul_coder.domain.groupchat.entity.GroupChatMessage; +import grep.neogul_coder.domain.groupchat.entity.GroupChatRoom; +import grep.neogul_coder.domain.groupchat.controller.dto.requset.GroupChatMessageRequestDto; +import grep.neogul_coder.domain.groupchat.controller.dto.response.GroupChatMessageResponseDto; +import grep.neogul_coder.domain.groupchat.repository.GroupChatMessageRepository; +import grep.neogul_coder.domain.groupchat.repository.GroupChatRoomRepository; +import grep.neogul_coder.domain.study.repository.StudyMemberRepository; +import grep.neogul_coder.domain.users.entity.User; +import grep.neogul_coder.domain.users.repository.UserRepository; +import grep.neogul_coder.global.response.PageResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; + +@Service +public class GroupChatService { + + private final GroupChatMessageRepository messageRepository; + private final GroupChatRoomRepository roomRepository; + private final UserRepository userRepository; + private final StudyMemberRepository studyMemberRepository; + + // 생성자 주입을 통한 의존성 주입 + public GroupChatService(GroupChatMessageRepository messageRepository, + GroupChatRoomRepository roomRepository, + UserRepository userRepository, + StudyMemberRepository studyMemberRepository) { + this.messageRepository = messageRepository; + this.roomRepository = roomRepository; + this.userRepository = userRepository; + this.studyMemberRepository = studyMemberRepository; + } + + public GroupChatMessageResponseDto saveMessage(GroupChatMessageRequestDto requestDto) { + // 채팅방 존재 여부 확인 + GroupChatRoom room = roomRepository.findById(requestDto.getRoomId()) + .orElseThrow(() -> new IllegalArgumentException("채팅방이 존재하지 않습니다.")); + // 메시지 발신자(사용자) 정보 조회 + User sender = userRepository.findById(requestDto.getSenderId()) + .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); + + // 스터디 참가자 검증 로직 추가 + Long studyId = room.getStudyId(); + boolean isParticipant = studyMemberRepository.existsByStudyIdAndUserId(studyId, sender.getId()); + if (!isParticipant) { + throw new IllegalArgumentException("해당 스터디에 참가한 사용자만 채팅할 수 있습니다."); + } + + // 메시지 생성 및 저장 + GroupChatMessage message = new GroupChatMessage(); + message.setGroupChatRoom(room); // 엔티티에 있는 필드명 맞게 사용 + message.setUserId(sender.getId()); // 연관관계 없이 userId만 저장 + message.setMessage(requestDto.getMessage()); + message.setSentAt(LocalDateTime.now()); + + // 메시지 저장 + messageRepository.save(message); + + // 저장된 메시지를 dto로 변환 + return new GroupChatMessageResponseDto( + message.getMessageId(), + message.getGroupChatRoom().getRoomId(), // ← roomId 필드가 필요하면 여기서 꺼내야 함 + sender.getId(), + sender.getNickname(), + sender.getProfileImageUrl(), + message.getMessage(), + message.getSentAt() + ); + } + + // 과거 채팅 메시지 페이징 조회 (무한 스크롤용) + public PageResponse getMessages(Long roomId, int page, int size) { + // 오래된 메시지부터 조회되도록 정렬 기준을 ASC로 설정 + Pageable pageable = PageRequest.of(page, size, Sort.by("sentAt").ascending()); + + // JPQL 쿼리 메서드 기반 조회 + Page messages = messageRepository.findMessagesByRoomIdAsc(roomId, pageable); + + // 응답 DTO로 변환 + Page messagePage = messages.map(message -> { + User sender = userRepository.findById(message.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")); + + return new GroupChatMessageResponseDto( + message.getMessageId(), + message.getGroupChatRoom().getRoomId(), + sender.getId(), + sender.getNickname(), + sender.getProfileImageUrl(), + message.getMessage(), + message.getSentAt() + ); + }); + + // PageResponse로 감싸서 반환 + return new PageResponse<>( + "/api/chat/room/" + roomId + "/messages", + messagePage, + 5 // 페이지 버튼 개수 + ); + } + + +} diff --git a/src/main/java/grep/neogul_coder/domain/prtemplate/controller/dto/response/PrPageResponse.java b/src/main/java/grep/neogul_coder/domain/prtemplate/controller/dto/response/PrPageResponse.java index c5232ea8..3f6f852a 100644 --- a/src/main/java/grep/neogul_coder/domain/prtemplate/controller/dto/response/PrPageResponse.java +++ b/src/main/java/grep/neogul_coder/domain/prtemplate/controller/dto/response/PrPageResponse.java @@ -1,12 +1,12 @@ package grep.neogul_coder.domain.prtemplate.controller.dto.response; -import grep.neogul_coder.domain.buddy.entity.BuddyEnergy; -import grep.neogul_coder.domain.prtemplate.entity.PrTemplate; +import grep.neogul_coder.domain.buddy.controller.dto.response.BuddyEnergyResponse; +import grep.neogul_coder.domain.review.ReviewType; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDate; import java.util.List; -import lombok.Builder; -import lombok.Data; + +import lombok.*; @Data @@ -21,10 +21,10 @@ public class PrPageResponse { private List userLocationAndLinks; @Schema(description = "버디 에너지 수치", example = "85") - private int buddyEnergy; + private BuddyEnergyResponse buddyEnergy; - @Schema(description = "리뷰 태그 목록") - private List reviewTags; + @Schema(description = "리뷰 타입 목록") + private List reviewTypes; @Schema(description = "리뷰 내용 목록") private List reviewContents; @@ -40,13 +40,22 @@ public static class UserLocationAndLink { @Schema(description = "위치", example = "서울") private String location; - @Schema(description = "링크 이름", example = "인스타그램") - private String linkName; + @Schema(description = "링크 목록") + private List links; + + @Data + @Builder + @Schema(description = "링크 정보") + public static class LinkInfo { + @Schema(description = "링크 이름", example = "인스타그램") + private String linkName; - @Schema(description = "링크 URL", example = "https://instagram.com/example") - private String link; + @Schema(description = "링크 URL", example = "https://instagram.com/example") + private String link; + } } + @Data @Builder @Schema(description = "유저 프로필 정보") @@ -59,16 +68,19 @@ public static class UserProfileDto { private String profileImgUrl; } - @Data + @Getter @Builder - @Schema(description = "리뷰 태그 정보") - public static class ReviewTagDto { + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "리뷰 타입 정보") + public static class ReviewTypeDto { @Schema(description = "리뷰 유형", example = "친절함") - private String reviewType; + private ReviewType reviewType; @Schema(description = "리뷰 개수", example = "12") private int reviewCount; + } @Data @@ -76,9 +88,12 @@ public static class ReviewTagDto { @Schema(description = "리뷰 내용 정보") public static class ReviewContentDto { - @Schema(description = "리뷰한 사용자 ID", example = "101") + @Schema(description = "리뷰한 사용자 ID", example = "4") private Long reviewUserId; + @Schema(description = "리뷰한 사용자 닉네임", example = "홍길동") + private String reviewUserNickname; + @Schema(description = "리뷰한 사용자 프로필 이미지 URL", example = "https://example.com/reviewer.jpg") private String reviewUserImgUrl; diff --git a/src/main/java/grep/neogul_coder/domain/prtemplate/entity/Link.java b/src/main/java/grep/neogul_coder/domain/prtemplate/entity/Link.java index e4635abc..38f89ca1 100644 --- a/src/main/java/grep/neogul_coder/domain/prtemplate/entity/Link.java +++ b/src/main/java/grep/neogul_coder/domain/prtemplate/entity/Link.java @@ -37,7 +37,7 @@ public static Link LinkInit(Long prId, String prUrl, String urlName) { .prId(prId) .prUrl(prUrl) .urlName(urlName) - .activated(true) + .activated(false) .build(); } diff --git a/src/main/java/grep/neogul_coder/domain/prtemplate/repository/PrTemplateQueryRepository.java b/src/main/java/grep/neogul_coder/domain/prtemplate/repository/PrTemplateQueryRepository.java new file mode 100644 index 00000000..85802a63 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/prtemplate/repository/PrTemplateQueryRepository.java @@ -0,0 +1,41 @@ +package grep.neogul_coder.domain.prtemplate.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import grep.neogul_coder.domain.prtemplate.controller.dto.response.PrPageResponse; +import grep.neogul_coder.domain.review.entity.QMyReviewTagEntity; +import grep.neogul_coder.domain.review.entity.QReviewEntity; +import grep.neogul_coder.domain.review.entity.QReviewTagEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class PrTemplateQueryRepository { + + private final JPAQueryFactory queryFactory; + + public List findReviewTypeCountsByTargetUser(Long targetUserId) { + QReviewEntity review = QReviewEntity.reviewEntity; + QMyReviewTagEntity myReviewTag = QMyReviewTagEntity.myReviewTagEntity; + QReviewTagEntity reviewTag = QReviewTagEntity.reviewTagEntity; + + return queryFactory + .select(Projections.constructor( + PrPageResponse.ReviewTypeDto.class, + reviewTag.reviewType, + myReviewTag.count().intValue() + )) + .from(myReviewTag) + .join(myReviewTag.reviewEntity, review) + .join(myReviewTag.reviewTag, reviewTag) + .where(review.targetUserId.eq(targetUserId)) + .groupBy(reviewTag.reviewType) + .fetch(); + } + + + +} diff --git a/src/main/java/grep/neogul_coder/domain/prtemplate/service/PrTemplateService.java b/src/main/java/grep/neogul_coder/domain/prtemplate/service/PrTemplateService.java index 5e2f6cfc..fcd3022d 100644 --- a/src/main/java/grep/neogul_coder/domain/prtemplate/service/PrTemplateService.java +++ b/src/main/java/grep/neogul_coder/domain/prtemplate/service/PrTemplateService.java @@ -1,13 +1,13 @@ package grep.neogul_coder.domain.prtemplate.service; -import grep.neogul_coder.domain.buddy.entity.BuddyEnergy; -import grep.neogul_coder.domain.buddy.repository.BuddyEnergyRepository; +import grep.neogul_coder.domain.buddy.controller.dto.response.BuddyEnergyResponse; +import grep.neogul_coder.domain.buddy.service.BuddyEnergyService; import grep.neogul_coder.domain.prtemplate.controller.dto.response.PrPageResponse; import grep.neogul_coder.domain.prtemplate.entity.Link; import grep.neogul_coder.domain.prtemplate.entity.PrTemplate; -import grep.neogul_coder.domain.prtemplate.exception.TemplateNotFoundException; import grep.neogul_coder.domain.prtemplate.exception.code.PrTemplateErrorCode; import grep.neogul_coder.domain.prtemplate.repository.LinkRepository; +import grep.neogul_coder.domain.prtemplate.repository.PrTemplateQueryRepository; import grep.neogul_coder.domain.prtemplate.repository.PrTemplateRepository; import grep.neogul_coder.domain.review.entity.ReviewEntity; import grep.neogul_coder.domain.review.repository.ReviewRepository; @@ -16,107 +16,115 @@ import grep.neogul_coder.domain.users.exception.code.UserErrorCode; import grep.neogul_coder.domain.users.repository.UserRepository; import grep.neogul_coder.global.exception.business.NotFoundException; -import jakarta.transaction.Transactional; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; @Service @RequiredArgsConstructor -@Transactional public class PrTemplateService { private final PrTemplateRepository prTemplateRepository; + private final PrTemplateQueryRepository prTemplateQueryRepository; private final LinkRepository linkRepository; private final ReviewRepository reviewRepository; - private final BuddyEnergyRepository buddyEnergyRepository; + private final BuddyEnergyService buddyEnergyService; private final UserRepository userRepository; + @Transactional public void deleteByUserId(Long userId) { PrTemplate prTemplate = getPrTemplateByUserId(userId); prTemplate.delete(); } + @Transactional public void update(Long userId, String location) { PrTemplate prTemplate = getPrTemplateByUserId(userId); prTemplate.update(location); } + @Transactional public void updateIntroduction(Long userId, String introduction) { PrTemplate prTemplate = getPrTemplateByUserId(userId); prTemplate.updateIntroduction(introduction); } + @Transactional(readOnly = true) public PrPageResponse toResponse(Long userId) { - User user = userRepository.findById(userId).orElseThrow(() -> new NotFoundException( - UserErrorCode.USER_NOT_FOUND)); PrTemplate prTemplate = getPrTemplateByUserId(userId); List links = linkRepository.findAllByUserIdAndActivatedTrue(userId); List reviews = reviewRepository.findAllByTargetUserId(userId); - List userProfiles = List.of( - PrPageResponse.UserProfileDto.builder() - .nickname(user.getNickname()) - .profileImgUrl(user.getProfileImageUrl()) - .build() + List prUser = getPrUser(userId); + List userLocationAndLinks = getUserLocationAndLinks(links, prTemplate); + BuddyEnergyResponse buddyEnergy = buddyEnergyService.getBuddyEnergy(userId); + List reviewTypeDto = getReviewTypeDto(userId); + List reviewContents = getReviewContents(reviews); + + return PrPageResponse.builder() + .userProfiles(prUser) + .userLocationAndLinks(userLocationAndLinks) + .buddyEnergy(buddyEnergy) + .reviewTypes(reviewTypeDto) + .reviewContents(reviewContents) + .introduction(prTemplate.getIntroduction()) + .build(); + } + + private PrTemplate getPrTemplateByUserId(Long userId) { + return prTemplateRepository.findByUserId(userId).orElseThrow( + () -> new NotFoundException(PrTemplateErrorCode.TEMPLATE_NOT_FOUND)); + } + + private List getPrUser(Long userId) { + User user = userRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(UserErrorCode.USER_NOT_FOUND)); + return List.of( + PrPageResponse.UserProfileDto.builder() + .nickname(user.getNickname()) + .profileImgUrl(user.getProfileImageUrl()) + .build() ); + } - List userLocationAndLinks = links.stream() - .filter(Link::getActivated) - .map(link -> PrPageResponse.UserLocationAndLink.builder() + private List getUserLocationAndLinks(List links, PrTemplate prTemplate) { + return List.of(PrPageResponse.UserLocationAndLink.builder() .location(prTemplate.getLocation()) - .linkName(link.getUrlName()) - .link(link.getPrUrl()) - .build()) - .toList(); - - int buddyEnergy = buddyEnergyRepository.findByUserId(userId) - .map(BuddyEnergy::getLevel) - .orElse(50); - - Map tagMap = reviews.stream() - .flatMap(review -> review.getReviewTags().stream()) - .map(myTag -> myTag.getReviewTag().getReviewTag()) - .collect(Collectors.groupingBy(tag -> tag, Collectors.counting())); - - List reviewTags = tagMap.entrySet().stream() - .map(entry -> PrPageResponse.ReviewTagDto.builder() - .reviewType(entry.getKey()) - .reviewCount(entry.getValue().intValue()) - .build()) - .toList(); - - List reviewContents = reviews.stream() - .sorted(Comparator.comparing(ReviewEntity::getCreatedDate).reversed()) - .limit(5) - .map(review -> { - User writer = userRepository.findById(review.getWriteUserId()) - .orElseThrow(() -> new UserNotFoundException(UserErrorCode.USER_NOT_FOUND)); - return PrPageResponse.ReviewContentDto.builder() - .reviewUserId(writer.getId()) - .reviewUserImgUrl(writer.getProfileImageUrl()) - .reviewComment(review.getContent()) - .reviewDate(review.getCreatedDate().toLocalDate()) - .build(); - }) - .toList(); + .links(toLinkInfoList(links)) + .build()); + } - return PrPageResponse.builder() - .userProfiles(userProfiles) - .userLocationAndLinks(userLocationAndLinks) - .buddyEnergy(buddyEnergy) - .reviewTags(reviewTags) - .reviewContents(reviewContents) - .introduction(prTemplate.getIntroduction()) - .build(); + private List toLinkInfoList(List links) { + return links.stream() + .map(link -> PrPageResponse.UserLocationAndLink.LinkInfo.builder() + .linkName(link.getUrlName()) + .link(link.getPrUrl()) + .build()) + .toList(); } - public PrTemplate getPrTemplateByUserId(Long userId) { - return prTemplateRepository.findByUserId(userId).orElseThrow( - () -> new NotFoundException(PrTemplateErrorCode.TEMPLATE_NOT_FOUND)); + private List getReviewTypeDto(Long targetUserId) { + return prTemplateQueryRepository.findReviewTypeCountsByTargetUser(targetUserId); + } + + private List getReviewContents(List reviews) { + return reviews.stream() + .sorted(Comparator.comparing(ReviewEntity::getCreatedDate).reversed()) + .limit(5) + .map(review -> { + User writer = userRepository.findById(review.getWriteUserId()).orElseThrow(() -> new UserNotFoundException(UserErrorCode.USER_NOT_FOUND)); + return PrPageResponse.ReviewContentDto.builder() + .reviewUserId(writer.getId()) + .reviewUserNickname(writer.getNickname()) + .reviewUserImgUrl(writer.getProfileImageUrl()) + .reviewComment(review.getContent()) + .reviewDate(review.getCreatedDate().toLocalDate()) + .build(); + }) + .toList(); + } -} \ No newline at end of file +} diff --git a/src/main/java/grep/neogul_coder/domain/quiz/controller/AiQuizController.java b/src/main/java/grep/neogul_coder/domain/quiz/controller/AiQuizController.java index 9bc44541..c75e7f1d 100644 --- a/src/main/java/grep/neogul_coder/domain/quiz/controller/AiQuizController.java +++ b/src/main/java/grep/neogul_coder/domain/quiz/controller/AiQuizController.java @@ -8,29 +8,30 @@ import grep.neogul_coder.domain.quiz.service.AiQuizServiceImpl; import grep.neogul_coder.domain.studypost.Category; import grep.neogul_coder.domain.studypost.StudyPost; -import grep.neogul_coder.domain.studypost.service.StudyPostService; +import grep.neogul_coder.domain.studypost.repository.StudyPostRepository; import grep.neogul_coder.global.response.ApiResponse; -import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.Optional; + @RestController @RequiredArgsConstructor @RequestMapping("/api/post/ai") public class AiQuizController implements AiQuizSpecification { - private final StudyPostService studyPostService; + private final StudyPostRepository studyPostRepository; private final AiQuizServiceImpl aiQuizServiceImpl; private final AiQuizRepository aiQuizRepository; @GetMapping("/{postId}") public ApiResponse get(@PathVariable("postId") Long postId) { - StudyPost post = studyPostService.findById(postId); + StudyPost post = studyPostRepository.findById(postId).orElseThrow(); if (!Category.FREE.equals((post.getCategory()))) { throw new PostNotFreeException(QuizErrorCode.POST_NOT_FREE_ERROR); diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/RecruitmentErrorCode.java b/src/main/java/grep/neogul_coder/domain/recruitment/RecruitmentErrorCode.java index 9be863d7..ef18fade 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/RecruitmentErrorCode.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/RecruitmentErrorCode.java @@ -9,6 +9,7 @@ public enum RecruitmentErrorCode implements ErrorCode { NOT_STUDY_LEADER(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.name(), "스터디의 리더가 아닙니다."), NOT_FOUND_STUDY_MEMBER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "스터디에 참여하고 있지 않은 회원 입니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "모집글을 찾지 못했습니다."), + NOT_FOUND_COMMENT(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "댓글을 찾지 못했습니다"), NOT_OWNER(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.name(), "모집글을 등록한 당사자가 아닙니다."); private static final String BASIC_MESSAGE = "RECRUITMENT"; diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/RecruitmentPostComment.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/RecruitmentPostComment.java index d832d8a0..721db049 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/comment/RecruitmentPostComment.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/RecruitmentPostComment.java @@ -30,4 +30,12 @@ private RecruitmentPostComment(RecruitmentPost recruitmentPost, long userId, Str this.userId = userId; this.content = content; } + + public void update(String content) { + this.content = content; + } + + public void delete() { + this.activated = false; + } } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentController.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentController.java index f50ab3ab..b41ddb94 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentController.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentController.java @@ -2,31 +2,40 @@ import grep.neogul_coder.domain.recruitment.comment.controller.dto.request.RecruitmentCommentSaveRequest; import grep.neogul_coder.domain.recruitment.comment.controller.dto.request.RecruitmentCommentUpdateRequest; +import grep.neogul_coder.domain.recruitment.comment.service.RecruitmentPostCommentService; import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RequestMapping("/recruitment-posts/comments") +@RequiredArgsConstructor @RestController public class RecruitmentPostCommentController implements RecruitmentPostCommentSpecification { + private final RecruitmentPostCommentService commentService; + @PostMapping - public ApiResponse save(@RequestBody RecruitmentCommentSaveRequest request, + public ApiResponse save(@RequestBody @Valid RecruitmentCommentSaveRequest request, @AuthenticationPrincipal Principal userDetails) { - return ApiResponse.noContent(); + long commentId = commentService.save(request, userDetails.getUserId()); + return ApiResponse.success(commentId); } @PutMapping("/{comment-id}") public ApiResponse update(@PathVariable("comment-id") long commentId, @RequestBody RecruitmentCommentUpdateRequest request, @AuthenticationPrincipal Principal userDetails) { + commentService.update(request, commentId, userDetails.getUserId()); return ApiResponse.noContent(); } @DeleteMapping("/{comment-id}") public ApiResponse delete(@PathVariable("comment-id") long commentId, @AuthenticationPrincipal Principal userDetails) { + commentService.delete(commentId, userDetails.getUserId()); return ApiResponse.noContent(); } } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentSpecification.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentSpecification.java index 036ec150..71da396b 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/RecruitmentPostCommentSpecification.java @@ -11,7 +11,7 @@ public interface RecruitmentPostCommentSpecification { @Operation(summary = "모집글 댓글 작성", description = "모집글에 대한 댓글을 작성 합니다.") - ApiResponse save(RecruitmentCommentSaveRequest request, Principal userDetails); + ApiResponse save(RecruitmentCommentSaveRequest request, Principal userDetails); @Operation(summary = "모집글 댓글 수정", description = "모집글에 대한 댓글을 수정 합니다.") ApiResponse update(long commentId, RecruitmentCommentUpdateRequest request, Principal userDetails); diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentSaveRequest.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentSaveRequest.java index 34de215c..6b282d9c 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentSaveRequest.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentSaveRequest.java @@ -1,5 +1,7 @@ package grep.neogul_coder.domain.recruitment.comment.controller.dto.request; +import grep.neogul_coder.domain.recruitment.comment.RecruitmentPostComment; +import grep.neogul_coder.domain.recruitment.post.RecruitmentPost; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; @@ -11,4 +13,16 @@ public class RecruitmentCommentSaveRequest { @Schema(example = "저도 참여 할래요!", description = "모집글 내용") private String content; + + private RecruitmentCommentSaveRequest() { + } + + public RecruitmentPostComment toEntity(RecruitmentPost post, long userId){ + return RecruitmentPostComment.builder() + .recruitmentPost(post) + .userId(userId) + .content(this.content) + .build(); + } + } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentUpdateRequest.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentUpdateRequest.java index 31cf4d65..7747e5d3 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentUpdateRequest.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/controller/dto/request/RecruitmentCommentUpdateRequest.java @@ -6,9 +6,6 @@ @Getter public class RecruitmentCommentUpdateRequest { - @Schema(example = "2", description = "모집글 ID") - private long postId; - @Schema(example = "저도 참여 할래요!", description = "모집글 내용") private String content; } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/repository/RecruitmentPostCommentQueryRepository.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/repository/RecruitmentPostCommentQueryRepository.java index a2945b35..4e86733e 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/comment/repository/RecruitmentPostCommentQueryRepository.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/repository/RecruitmentPostCommentQueryRepository.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; import static grep.neogul_coder.domain.recruitment.comment.QRecruitmentPostComment.recruitmentPostComment; import static grep.neogul_coder.domain.users.entity.QUser.user; @@ -51,4 +52,15 @@ public List findCommentsWithWriterInfo(Long recruitmentP ) .fetch(); } + + public Optional findMyCommentBy(long commentId, long userId) { + RecruitmentPostComment comment = queryFactory.selectFrom(recruitmentPostComment) + .where( + recruitmentPostComment.activated.isTrue(), + recruitmentPostComment.id.eq(commentId), + recruitmentPostComment.userId.eq(userId) + ) + .fetchOne(); + return Optional.ofNullable(comment); + } } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/comment/service/RecruitmentPostCommentService.java b/src/main/java/grep/neogul_coder/domain/recruitment/comment/service/RecruitmentPostCommentService.java new file mode 100644 index 00000000..62a3bc37 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/recruitment/comment/service/RecruitmentPostCommentService.java @@ -0,0 +1,50 @@ +package grep.neogul_coder.domain.recruitment.comment.service; + +import grep.neogul_coder.domain.recruitment.RecruitmentErrorCode; +import grep.neogul_coder.domain.recruitment.comment.RecruitmentPostComment; +import grep.neogul_coder.domain.recruitment.comment.controller.dto.request.RecruitmentCommentSaveRequest; +import grep.neogul_coder.domain.recruitment.comment.controller.dto.request.RecruitmentCommentUpdateRequest; +import grep.neogul_coder.domain.recruitment.comment.repository.RecruitmentPostCommentQueryRepository; +import grep.neogul_coder.domain.recruitment.comment.repository.RecruitmentPostCommentRepository; +import grep.neogul_coder.domain.recruitment.post.RecruitmentPost; +import grep.neogul_coder.domain.recruitment.post.repository.RecruitmentPostQueryRepository; +import grep.neogul_coder.global.exception.business.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static grep.neogul_coder.domain.recruitment.RecruitmentErrorCode.*; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class RecruitmentPostCommentService { + + private final RecruitmentPostQueryRepository postRepository; + private final RecruitmentPostCommentRepository commentRepository; + private final RecruitmentPostCommentQueryRepository commentQueryRepository; + + @Transactional + public long save(RecruitmentCommentSaveRequest request, long userId) { + RecruitmentPost myPost = postRepository.findMyPostBy(request.getPostId(), userId) + .orElseThrow(() -> new NotFoundException(NOT_FOUND)); + + return commentRepository.save(request.toEntity(myPost, userId)).getId(); + } + + @Transactional + public void update(RecruitmentCommentUpdateRequest request, long commentId, long userId) { + RecruitmentPostComment comment = commentQueryRepository.findMyCommentBy(commentId, userId) + .orElseThrow(() -> new NotFoundException(NOT_FOUND_COMMENT)); + + comment.update(request.getContent()); + } + + @Transactional + public void delete(long commentId, long userId) { + RecruitmentPostComment comment = commentQueryRepository.findMyCommentBy(commentId, userId) + .orElseThrow(() -> new NotFoundException(NOT_FOUND_COMMENT)); + + comment.delete(); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/post/RecruitmentPost.java b/src/main/java/grep/neogul_coder/domain/recruitment/post/RecruitmentPost.java index e954a066..020ef8d7 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/post/RecruitmentPost.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/post/RecruitmentPost.java @@ -45,10 +45,11 @@ private RecruitmentPost(long studyId, String subject, String content, long userI protected RecruitmentPost() { } - public void update(String subject, String content, int recruitmentCount) { + public void update(String subject, String content, int recruitmentCount, LocalDateTime expiredDateTime) { this.subject = subject; this.content = content; this.recruitmentCount = recruitmentCount; + this.expiredDate = expiredDateTime; } public boolean isNotOwnedBy(long userId) { diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/post/controller/dto/request/RecruitmentPostUpdateRequest.java b/src/main/java/grep/neogul_coder/domain/recruitment/post/controller/dto/request/RecruitmentPostUpdateRequest.java index 5dd27c33..f0bbf822 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/post/controller/dto/request/RecruitmentPostUpdateRequest.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/post/controller/dto/request/RecruitmentPostUpdateRequest.java @@ -2,11 +2,14 @@ import grep.neogul_coder.domain.recruitment.post.service.request.RecruitmentPostUpdateServiceRequest; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Future; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Positive; import lombok.Builder; import lombok.Getter; +import java.time.LocalDateTime; + @Getter public class RecruitmentPostUpdateRequest { @@ -22,11 +25,16 @@ public class RecruitmentPostUpdateRequest { @NotBlank(message = "내용은 필수값 입니다.") private String content; + @Future + @Schema(example = "2025-07-21T23:59:59", description = "모집글 마감 기간") + private LocalDateTime expiredDateTime; + @Builder - private RecruitmentPostUpdateRequest(String subject, String content, int recruitmentCount) { + private RecruitmentPostUpdateRequest(String subject, String content, int recruitmentCount, LocalDateTime expiredDateTime) { this.subject = subject; this.content = content; this.recruitmentCount = recruitmentCount; + this.expiredDateTime = expiredDateTime; } public RecruitmentPostUpdateServiceRequest toServiceRequest() { @@ -34,6 +42,7 @@ public RecruitmentPostUpdateServiceRequest toServiceRequest() { .subject(this.subject) .content(this.content) .recruitmentCount(this.recruitmentCount) + .expiredDateTime(this.expiredDateTime) .build(); } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostQueryRepository.java b/src/main/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostQueryRepository.java index a62d3f80..c62155b9 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostQueryRepository.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostQueryRepository.java @@ -3,6 +3,7 @@ import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; +import grep.neogul_coder.domain.recruitment.post.QRecruitmentPost; import grep.neogul_coder.domain.recruitment.post.RecruitmentPost; import grep.neogul_coder.domain.recruitment.post.controller.dto.request.PagingCondition; import grep.neogul_coder.domain.recruitment.post.controller.dto.response.QRecruitmentPostWithStudyInfo; @@ -15,6 +16,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; import java.util.function.Supplier; import static grep.neogul_coder.domain.recruitment.post.QRecruitmentPost.recruitmentPost; @@ -123,6 +125,17 @@ public Page findAllByFilter(PagingCondition condition, Long use return new PageImpl<>(content, condition.toPageable(), count == null ? 0 : count); } + public Optional findMyPostBy(long postId, long userId) { + RecruitmentPost findRecruitmentPost = queryFactory.selectFrom(recruitmentPost) + .where( + recruitmentPost.activated.isTrue(), + recruitmentPost.userId.eq(userId), + recruitmentPost.id.eq(postId) + ) + .fetchOne(); + return Optional.ofNullable(findRecruitmentPost); + } + private BooleanBuilder equalsStudyType(StudyType studyType) { return nullSafeBuilder(() -> study.studyType.eq(studyType)); } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostService.java b/src/main/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostService.java index fbbb117e..af49d8b8 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostService.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/post/service/RecruitmentPostService.java @@ -78,7 +78,8 @@ public long update(RecruitmentPostUpdateServiceRequest request, long recruitment recruitmentPost.update( request.getSubject(), request.getContent(), - request.getRecruitmentCount() + request.getRecruitmentCount(), + request.getExpiredDateTime() ); return recruitmentPost.getId(); } diff --git a/src/main/java/grep/neogul_coder/domain/recruitment/post/service/request/RecruitmentPostUpdateServiceRequest.java b/src/main/java/grep/neogul_coder/domain/recruitment/post/service/request/RecruitmentPostUpdateServiceRequest.java index 8760ab7e..4aa617ea 100644 --- a/src/main/java/grep/neogul_coder/domain/recruitment/post/service/request/RecruitmentPostUpdateServiceRequest.java +++ b/src/main/java/grep/neogul_coder/domain/recruitment/post/service/request/RecruitmentPostUpdateServiceRequest.java @@ -3,16 +3,20 @@ import lombok.Builder; import lombok.Getter; +import java.time.LocalDateTime; + @Getter public class RecruitmentPostUpdateServiceRequest { private String subject; private String content; private int recruitmentCount; + private LocalDateTime expiredDateTime; @Builder - private RecruitmentPostUpdateServiceRequest(String subject, String content, int recruitmentCount) { + private RecruitmentPostUpdateServiceRequest(String subject, String content, int recruitmentCount, LocalDateTime expiredDateTime) { this.subject = subject; this.content = content; this.recruitmentCount = recruitmentCount; + this.expiredDateTime = expiredDateTime; } } diff --git a/src/main/java/grep/neogul_coder/domain/study/StudyMember.java b/src/main/java/grep/neogul_coder/domain/study/StudyMember.java index 23df5fa1..66e92859 100644 --- a/src/main/java/grep/neogul_coder/domain/study/StudyMember.java +++ b/src/main/java/grep/neogul_coder/domain/study/StudyMember.java @@ -25,7 +25,8 @@ public class StudyMember extends BaseEntity { private boolean participated; - protected StudyMember() {} + protected StudyMember() { + } @Builder public StudyMember(Study study, Long userId, StudyMemberRole role) { @@ -43,7 +44,7 @@ public boolean isLeader() { return this.role == StudyMemberRole.LEADER; } - public boolean hasNotRoleLeader(){ + public boolean hasNotRoleLeader() { return this.role != StudyMemberRole.LEADER; } diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/StudyController.java b/src/main/java/grep/neogul_coder/domain/study/controller/StudyController.java index 0a5c04ec..e831131d 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/StudyController.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/StudyController.java @@ -10,9 +10,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; @RequestMapping("/api/studies") @@ -65,18 +68,20 @@ public ApiResponse getMyStudyMemberInfo(@PathVariable(" return ApiResponse.success(studyService.getMyStudyMemberInfo(studyId, userId)); } - @PostMapping - public ApiResponse createStudy(@RequestBody @Valid StudyCreateRequest request, - @AuthenticationPrincipal Principal userDetails) { - Long id = studyService.createStudy(request, userDetails.getUserId()); + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse createStudy(@RequestPart("request") @Valid StudyCreateRequest request, + @RequestPart(value = "image", required = false) MultipartFile image, + @AuthenticationPrincipal Principal userDetails) throws IOException { + Long id = studyService.createStudy(request, userDetails.getUserId(), image); return ApiResponse.success(id); } @PutMapping("/{studyId}") public ApiResponse updateStudy(@PathVariable("studyId") Long studyId, - @RequestBody @Valid StudyUpdateRequest request, - @AuthenticationPrincipal Principal userDetails) { - studyService.updateStudy(studyId, request, userDetails.getUserId()); + @RequestPart @Valid StudyUpdateRequest request, + @RequestPart(value = "image", required = false) MultipartFile image, + @AuthenticationPrincipal Principal userDetails) throws IOException { + studyService.updateStudy(studyId, request, userDetails.getUserId(), image); return ApiResponse.noContent(); } diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/StudySpecification.java b/src/main/java/grep/neogul_coder/domain/study/controller/StudySpecification.java index e97c4767..407173b2 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/StudySpecification.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/StudySpecification.java @@ -8,7 +8,9 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.data.domain.Pageable; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; @Tag(name = "Study", description = "스터디 API") @@ -36,10 +38,10 @@ public interface StudySpecification { ApiResponse getMyStudyMemberInfo(Long studyId, Principal userDetails); @Operation(summary = "스터디 생성", description = "새로운 스터디를 생성합니다.") - ApiResponse createStudy(StudyCreateRequest request, Principal userDetails); + ApiResponse createStudy(StudyCreateRequest request, MultipartFile image, Principal userDetails) throws IOException; @Operation(summary = "스터디 수정", description = "스터디를 수정합니다.") - ApiResponse updateStudy(Long studyId, StudyUpdateRequest request, Principal userDetails); + ApiResponse updateStudy(Long studyId, StudyUpdateRequest request, MultipartFile image, Principal userDetails) throws IOException; @Operation(summary = "스터디 삭제", description = "스터디를 삭제합니다.") ApiResponse deleteStudy(Long studyId, Principal userDetails); diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyCreateRequest.java b/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyCreateRequest.java index 5c95fc46..9932c223 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyCreateRequest.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyCreateRequest.java @@ -45,7 +45,6 @@ public class StudyCreateRequest { @Schema(description = "스터디 소개", example = "자바 스터디입니다.") private String introduction; - @NotBlank @Schema(description = "대표 이미지", example = "http://localhost:8083/image.jpg") private String imageUrl; @@ -65,7 +64,7 @@ private StudyCreateRequest(String name, Category category, int capacity, StudyTy this.imageUrl = imageUrl; } - public Study toEntity() { + public Study toEntity(String imageUrl) { return Study.builder() .name(this.name) .category(this.category) @@ -75,7 +74,7 @@ public Study toEntity() { .startDate(this.startDate) .endDate(this.endDate) .introduction(this.introduction) - .imageUrl(this.imageUrl) + .imageUrl(imageUrl) .build(); } } diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyUpdateRequest.java b/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyUpdateRequest.java index 0480583c..e6a224ef 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyUpdateRequest.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/dto/request/StudyUpdateRequest.java @@ -38,7 +38,6 @@ public class StudyUpdateRequest { @Schema(description = "스터디 소개", example = "자바 스터디입니다.") private String introduction; - @NotBlank @Schema(description = "대표 이미지", example = "http://localhost:8083/image.jpg") private String imageUrl; @@ -57,7 +56,7 @@ private StudyUpdateRequest(String name, Category category, int capacity, StudyTy this.imageUrl = imageUrl; } - public Study toEntity() { + public Study toEntity(String imageUrl) { return Study.builder() .name(this.name) .category(this.category) @@ -66,7 +65,7 @@ public Study toEntity() { .location(this.location) .startDate(this.startDate) .introduction(this.introduction) - .imageUrl(this.imageUrl) + .imageUrl(imageUrl) .build(); } } diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyMyContentResponse.java b/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyMyContentResponse.java index 0fdc6796..b6459d21 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyMyContentResponse.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyMyContentResponse.java @@ -1,6 +1,6 @@ package grep.neogul_coder.domain.study.controller.dto.response; -import grep.neogul_coder.domain.studypost.dto.StudyPostListResponse; +import grep.neogul_coder.domain.studypost.controller.dto.StudyPostListResponse; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; diff --git a/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyResponse.java b/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyResponse.java index 49102dd5..f661ae00 100644 --- a/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyResponse.java +++ b/src/main/java/grep/neogul_coder/domain/study/controller/dto/response/StudyResponse.java @@ -2,7 +2,7 @@ import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceResponse; import grep.neogul_coder.domain.calender.controller.dto.response.TeamCalendarResponse; -import grep.neogul_coder.domain.studypost.dto.StudyPostListResponse; +import grep.neogul_coder.domain.studypost.controller.dto.StudyPostListResponse; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; diff --git a/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java b/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java index e508d69d..239a7fe8 100644 --- a/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java +++ b/src/main/java/grep/neogul_coder/domain/study/repository/StudyMemberRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -31,4 +32,9 @@ public interface StudyMemberRepository extends JpaRepository @Query("select m from StudyMember m where m.study.id = :studyId and m.role = 'MEMBER' and m.activated = true") List findAvailableNewLeaders(@Param("studyId") Long studyId); + + @Query("select m.createdDate from StudyMember m where m.study.id = :studyId and m.userId = :userId and m.activated = true") + LocalDateTime findCreatedDateByStudyIdAndUserId(@Param("studyId") Long studyId, @Param("userId") Long userId); + + boolean existsByStudyIdAndUserId(Long studyId, Long id); } diff --git a/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java b/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java index e02c4985..5b6cc333 100644 --- a/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java +++ b/src/main/java/grep/neogul_coder/domain/study/service/StudyService.java @@ -17,12 +17,20 @@ import grep.neogul_coder.domain.users.repository.UserRepository; import grep.neogul_coder.global.exception.business.BusinessException; import grep.neogul_coder.global.exception.business.NotFoundException; +import grep.neogul_coder.global.utils.upload.FileUploadResponse; +import grep.neogul_coder.global.utils.upload.FileUsageType; +import grep.neogul_coder.global.utils.upload.uploader.GcpFileUploader; +import grep.neogul_coder.global.utils.upload.uploader.LocalFileUploader; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; import java.util.Optional; @@ -43,6 +51,15 @@ public class StudyService { private final UserRepository userRepository; private final BuddyEnergyService buddyEnergyService; + @Autowired(required = false) + private GcpFileUploader gcpFileUploader; + + @Autowired(required = false) + private LocalFileUploader localFileUploader; + + @Autowired + private Environment environment; + public StudyItemPagingResponse getMyStudiesPaging(Pageable pageable, Long userId) { Page page = studyQueryRepository.findMyStudiesPaging(pageable, userId); return StudyItemPagingResponse.of(page); @@ -90,10 +107,12 @@ public StudyMemberInfoResponse getMyStudyMemberInfo(Long studyId, Long userId) { } @Transactional - public Long createStudy(StudyCreateRequest request, Long userId) { + public Long createStudy(StudyCreateRequest request, Long userId, MultipartFile image) throws IOException { validateLocation(request.getStudyType(), request.getLocation()); - Study study = studyRepository.save(request.toEntity()); + String imageUrl = createImageUrl(userId, image); + + Study study = studyRepository.save(request.toEntity(imageUrl)); StudyMember leader = StudyMember.builder() .study(study) @@ -106,7 +125,7 @@ public Long createStudy(StudyCreateRequest request, Long userId) { } @Transactional - public void updateStudy(Long studyId, StudyUpdateRequest request, Long userId) { + public void updateStudy(Long studyId, StudyUpdateRequest request, Long userId, MultipartFile image) throws IOException { Study study = studyRepository.findById(studyId) .orElseThrow(() -> new NotFoundException(STUDY_NOT_FOUND)); @@ -115,6 +134,8 @@ public void updateStudy(Long studyId, StudyUpdateRequest request, Long userId) { validateStudyLeader(studyId, userId); validateStudyStartDate(request, study); + String imageUrl = updateImageUrl(userId, image, study.getImageUrl()); + study.update( request.getName(), request.getCategory(), @@ -123,7 +144,7 @@ public void updateStudy(Long studyId, StudyUpdateRequest request, Long userId) { request.getLocation(), request.getStartDate(), request.getIntroduction(), - request.getImageUrl() + imageUrl ); } @@ -181,4 +202,39 @@ private void validateStudyDeletable(Long studyId) { throw new BusinessException(STUDY_DELETE_NOT_ALLOWED); } } + + private boolean isProductionEnvironment() { + for (String profile : environment.getActiveProfiles()) { + if ("prod".equals(profile)) { + return true; + } + } + return false; + } + + private String createImageUrl(Long userId, MultipartFile image) throws IOException { + String imageUrl = null; + if (isImgExists(image)) { + FileUploadResponse uploadResult = isProductionEnvironment() + ? gcpFileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId) + : localFileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId); + imageUrl = uploadResult.getFileUrl(); + } + return imageUrl; + } + + private String updateImageUrl(Long userId, MultipartFile image, String originalImageUrl) throws IOException { + if (isImgExists(image)) { + FileUploadResponse uploadResult = isProductionEnvironment() + ? gcpFileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId) + : localFileUploader.upload(image, userId, FileUsageType.STUDY_COVER, userId); + return uploadResult.getFileUrl(); + } + return originalImageUrl; + } + + + private boolean isImgExists(MultipartFile image) { + return image != null && !image.isEmpty(); + } } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/Category.java b/src/main/java/grep/neogul_coder/domain/studypost/Category.java index e9551eee..6693f78c 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/Category.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/Category.java @@ -16,4 +16,8 @@ public enum Category { public String toJson() { return korean; } + + public String getKorean() { + return korean; + } } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/StudyPost.java b/src/main/java/grep/neogul_coder/domain/studypost/StudyPost.java index bcd5e230..7aa2d18c 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/StudyPost.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/StudyPost.java @@ -2,44 +2,57 @@ import grep.neogul_coder.domain.study.Study; import grep.neogul_coder.global.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.validation.constraints.NotBlank; +import jakarta.persistence.*; +import lombok.Builder; import lombok.Getter; @Getter @Entity public class StudyPost extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "study_id", nullable = false) - private Study study; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_id", nullable = false) + private Study study; - @Column(nullable = false) - private Long userId; + @Column(nullable = false) + private Long userId; - @NotBlank(message = "제목은 필수입니다.") - @Column(nullable = false) - private String title; + @Column(nullable = false) + private String title; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private Category category; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Category category; - @NotBlank(message = "내용은 필수입니다.") - @Column(nullable = false) - private String content; + @Column(nullable = false) + private String content; + protected StudyPost() { + } + + @Builder + private StudyPost(Long userId, String title, Category category, String content) { + this.userId = userId; + this.title = title; + this.category = category; + this.content = content; + } + + public void connectStudy(Study study) { + this.study = study; + } + + public void update(Category category, String title, String content) { + this.category = category; + this.title = title; + this.content = content; + } + + public void delete() { + this.activated = false; + } } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java b/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java new file mode 100644 index 00000000..f66490d5 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/StudyPostErrorCode.java @@ -0,0 +1,23 @@ +package grep.neogul_coder.domain.studypost; + +import grep.neogul_coder.global.response.code.ErrorCode; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum StudyPostErrorCode implements ErrorCode { + NOT_VALID_CONDITION(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.name(), "잘못된 쿼리 조건 입니다."), + NOT_JOINED_STUDY_USER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "해당 스터디에 참여 하고 있지 않은 회원 입니다."), + NOT_FOUND_POST(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.name(), "해당 스터디 게시글을 찾지 못했습니다."); + + private static final String BASIC_ERROR_NAME = "STUDY_POST"; + private final HttpStatus status; + private final String code; + private final String message; + + StudyPostErrorCode(HttpStatus status, String code, String message) { + this.status = status; + this.code = BASIC_ERROR_NAME + ": " + code; + this.message = message; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/comment/StudyPostComment.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/StudyPostComment.java new file mode 100644 index 00000000..6d0df59f --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/StudyPostComment.java @@ -0,0 +1,36 @@ +package grep.neogul_coder.domain.studypost.comment; + +import grep.neogul_coder.global.entity.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Entity +public class StudyPostComment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long postId; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false, length = 100) + @NotBlank(message = "내용은 필수입니다.") + private String content; + + protected StudyPostComment() { + } + + @Builder + private StudyPostComment(Long postId, Long userId, String content) { + this.postId = postId; + this.userId = userId; + this.content = content; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/comment/controller/CommentController.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/controller/CommentController.java similarity index 86% rename from src/main/java/grep/neogul_coder/domain/comment/controller/CommentController.java rename to src/main/java/grep/neogul_coder/domain/studypost/comment/controller/CommentController.java index f24a93b2..230c039e 100644 --- a/src/main/java/grep/neogul_coder/domain/comment/controller/CommentController.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/controller/CommentController.java @@ -1,7 +1,7 @@ -package grep.neogul_coder.domain.comment.controller; +package grep.neogul_coder.domain.studypost.comment.controller; -import grep.neogul_coder.domain.comment.dto.CommentRequest; -import grep.neogul_coder.domain.comment.dto.CommentResponse; +import grep.neogul_coder.domain.studypost.comment.dto.CommentRequest; +import grep.neogul_coder.domain.studypost.comment.dto.CommentResponse; import grep.neogul_coder.global.response.ApiResponse; import jakarta.validation.Valid; import java.util.List; diff --git a/src/main/java/grep/neogul_coder/domain/comment/controller/CommentSpecification.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/controller/CommentSpecification.java similarity index 89% rename from src/main/java/grep/neogul_coder/domain/comment/controller/CommentSpecification.java rename to src/main/java/grep/neogul_coder/domain/studypost/comment/controller/CommentSpecification.java index 34f54956..fa009632 100644 --- a/src/main/java/grep/neogul_coder/domain/comment/controller/CommentSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/controller/CommentSpecification.java @@ -1,7 +1,7 @@ -package grep.neogul_coder.domain.comment.controller; +package grep.neogul_coder.domain.studypost.comment.controller; -import grep.neogul_coder.domain.comment.dto.CommentRequest; -import grep.neogul_coder.domain.comment.dto.CommentResponse; +import grep.neogul_coder.domain.studypost.comment.dto.CommentRequest; +import grep.neogul_coder.domain.studypost.comment.dto.CommentResponse; import grep.neogul_coder.global.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; diff --git a/src/main/java/grep/neogul_coder/domain/comment/dto/CommentRequest.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentRequest.java similarity index 86% rename from src/main/java/grep/neogul_coder/domain/comment/dto/CommentRequest.java rename to src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentRequest.java index 881a8b03..5985b326 100644 --- a/src/main/java/grep/neogul_coder/domain/comment/dto/CommentRequest.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentRequest.java @@ -1,4 +1,4 @@ -package grep.neogul_coder.domain.comment.dto; +package grep.neogul_coder.domain.studypost.comment.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/grep/neogul_coder/domain/comment/dto/CommentResponse.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentResponse.java similarity index 79% rename from src/main/java/grep/neogul_coder/domain/comment/dto/CommentResponse.java rename to src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentResponse.java index fa56ee40..85381e62 100644 --- a/src/main/java/grep/neogul_coder/domain/comment/dto/CommentResponse.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/dto/CommentResponse.java @@ -1,4 +1,4 @@ -package grep.neogul_coder.domain.comment.dto; +package grep.neogul_coder.domain.studypost.comment.dto; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; @@ -8,9 +8,12 @@ @Schema(description = "스터디 게시글 댓글 응답 DTO") public class CommentResponse { - @Schema(description = "댓글 ID", example = "100") + @Schema(description = "댓글 ID", example = "3") private Long id; + @Schema(description = "회원 ID", example = "3") + private Long userId; + @Schema(description = "작성자 닉네임", example = "너굴코더") private String nickname; diff --git a/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentRepository.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentRepository.java new file mode 100644 index 00000000..f756ef00 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyCommentRepository.java @@ -0,0 +1,7 @@ +package grep.neogul_coder.domain.studypost.comment.repository; + +import grep.neogul_coder.domain.studypost.comment.StudyPostComment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudyCommentRepository extends JpaRepository { +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyPostCommentQueryRepository.java b/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyPostCommentQueryRepository.java new file mode 100644 index 00000000..d164895e --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/comment/repository/StudyPostCommentQueryRepository.java @@ -0,0 +1,66 @@ +package grep.neogul_coder.domain.studypost.comment.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import grep.neogul_coder.domain.studypost.comment.StudyPostComment; +import grep.neogul_coder.domain.studypost.controller.dto.response.CommentInfo; +import grep.neogul_coder.domain.studypost.controller.dto.response.QCommentInfo; +import jakarta.persistence.EntityManager; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static grep.neogul_coder.domain.studypost.QStudyPost.studyPost; +import static grep.neogul_coder.domain.studypost.comment.QStudyPostComment.studyPostComment; +import static grep.neogul_coder.domain.users.entity.QUser.user; + +@Repository +public class StudyPostCommentQueryRepository { + + private final EntityManager em; + private final JPAQueryFactory queryFactory; + + public StudyPostCommentQueryRepository(EntityManager em) { + this.em = em; + this.queryFactory = new JPAQueryFactory(em); + } + + public List findAllByPostId(Long postId) { + return queryFactory.selectFrom(studyPostComment) + .join(studyPost).on(studyPost.id.eq(studyPostComment.postId)) + .where( + studyPostComment.activated.isTrue(), + studyPostComment.postId.eq(postId) + ) + .fetch(); + } + + public List findWriterInfosByPostId(long postId) { + return queryFactory.select( + new QCommentInfo( + user.id, + user.nickname, + user.profileImageUrl, + studyPostComment.id, + studyPostComment.content, + studyPostComment.createdDate + ) + ) + .from(studyPostComment) + .join(user).on(studyPostComment.userId.eq(user.id)) + .where( + studyPostComment.activated.isTrue(), + studyPostComment.postId.eq(postId) + ) + .fetch(); + } + + public List findByPostIdIn(List postIds) { + return queryFactory.select(studyPostComment) + .from(studyPost) + .where( + studyPostComment.activated.isTrue(), + studyPostComment.postId.in(postIds) + ) + .fetch(); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java index 979e8c84..1763e5ef 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostController.java @@ -1,55 +1,57 @@ package grep.neogul_coder.domain.studypost.controller; -import grep.neogul_coder.domain.studypost.dto.StudyPostDetailResponse; -import grep.neogul_coder.domain.studypost.dto.StudyPostListResponse; -import grep.neogul_coder.domain.studypost.dto.StudyPostRequest; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostPagingCondition; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; +import grep.neogul_coder.domain.studypost.controller.dto.response.PostPagingResult; +import grep.neogul_coder.domain.studypost.controller.dto.response.StudyPostDetailResponse; +import grep.neogul_coder.domain.studypost.service.StudyPostService; +import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; import jakarta.validation.Valid; -import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@RequiredArgsConstructor +@RequestMapping("/api/posts") @RestController -@RequestMapping("/api/studies/{studyId}/posts") public class StudyPostController implements StudyPostSpecification { - @PostMapping - public ApiResponse create( - @PathVariable("studyId") Long studyId, - @RequestBody @Valid StudyPostRequest request - ) { - return ApiResponse.noContent(); - } - - @GetMapping("/all") - public ApiResponse> findAllWithoutPagination( - @PathVariable("studyId") Long studyId - ) { - List content = List.of(new StudyPostListResponse()); - return ApiResponse.success(content); - } - - @GetMapping("/{postId}") - public ApiResponse findOne( - @PathVariable("studyId") Long studyId, - @PathVariable("postId") Long postId - ) { - return ApiResponse.success(new StudyPostDetailResponse()); - } - - @PutMapping("/{postId}") - public ApiResponse update( - @PathVariable("studyId") Long studyId, - @PathVariable("postId") Long postId, - @RequestBody @Valid StudyPostRequest request - ) { - return ApiResponse.noContent(); - } - - @DeleteMapping("/{postId}") - public ApiResponse delete( - @PathVariable("studyId") Long studyId, - @PathVariable("postId") Long postId - ) { - return ApiResponse.noContent(); - } + private final StudyPostService studyPostService; + + @PostMapping + public ApiResponse create(@RequestBody @Valid StudyPostSaveRequest request, + @AuthenticationPrincipal Principal userDetails) { + long postId = studyPostService.create(request, userDetails.getUserId()); + return ApiResponse.success(postId); + } + + @GetMapping("/{postId}") + public ApiResponse findOne(@PathVariable("postId") Long postId) { + StudyPostDetailResponse response = studyPostService.findOne(postId); + return ApiResponse.success(response); + } + + @PostMapping("/studies/{study-id}") + public ApiResponse findPagingInfo(@PathVariable("study-id") Long studyId, + @RequestBody @Valid StudyPostPagingCondition condition) { + PostPagingResult response = studyPostService.findPagingInfo(condition, studyId); + return ApiResponse.success(response); + } + + @PutMapping("/{postId}") + public ApiResponse update(@PathVariable("postId") Long postId, + @RequestBody @Valid StudyPostUpdateRequest request, + @AuthenticationPrincipal Principal userDetails) { + studyPostService.update(request, postId, userDetails.getUserId()); + return ApiResponse.noContent(); + } + + @DeleteMapping("/{postId}") + public ApiResponse delete(@PathVariable("postId") Long postId, + @AuthenticationPrincipal Principal userDetails) { + studyPostService.delete(postId, userDetails.getUserId()); + return ApiResponse.noContent(); + } } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java index f6a77b02..6409e9a9 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/StudyPostSpecification.java @@ -1,45 +1,133 @@ package grep.neogul_coder.domain.studypost.controller; -import grep.neogul_coder.domain.studypost.dto.StudyPostDetailResponse; -import grep.neogul_coder.domain.studypost.dto.StudyPostListResponse; -import grep.neogul_coder.domain.studypost.dto.StudyPostRequest; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostPagingCondition; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; +import grep.neogul_coder.domain.studypost.controller.dto.response.PostPagingResult; +import grep.neogul_coder.domain.studypost.controller.dto.response.StudyPostDetailResponse; +import grep.neogul_coder.global.auth.Principal; import grep.neogul_coder.global.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import java.util.List; +import org.springframework.security.core.annotation.AuthenticationPrincipal; @Tag(name = "Study-Post", description = "스터디 게시판 API") public interface StudyPostSpecification { - @Operation(summary = "게시글 생성", description = "스터디에 새로운 게시글을 작성합니다.") - ApiResponse create( - @Parameter(description = "스터디 ID", example = "1") Long studyId, - @Valid StudyPostRequest request - ); - - @Operation(summary = "게시글 목록 전체 조회", description = "스터디의 게시글 전체 목록을 조회합니다.") - ApiResponse> findAllWithoutPagination( - @Parameter(description = "스터디 ID", example = "1") Long studyId - ); - - @Operation(summary = "게시글 상세 조회", description = "특정 게시글의 상세 정보를 조회합니다.") - ApiResponse findOne( - @Parameter(description = "스터디 ID", example = "1") Long studyId, - @Parameter(description = "게시글 ID", example = "15") Long postId - ); - - @Operation(summary = "게시글 수정", description = "기존 게시글을 수정합니다.") - ApiResponse update( - @Parameter(description = "스터디 ID", example = "1") Long studyId, - @Parameter(description = "게시글 ID", example = "15") Long postId, - @Valid StudyPostRequest request - ); - - @Operation(summary = "게시글 삭제", description = "특정 게시글을 삭제합니다.") - ApiResponse delete( - @Parameter(description = "스터디 ID", example = "1") Long studyId, - @Parameter(description = "게시글 ID", example = "15") Long postId - ); + @Operation(summary = "게시글 생성", description = "스터디에 새로운 게시글을 작성합니다.") + ApiResponse create(StudyPostSaveRequest request, Principal userDetails); + + @Operation( + summary = "게시글 목록 페이징 조회", + description = """ + 스터디의 게시글을 조건에 따라 페이징하여 조회합니다. + + ✅ 요청 예시: + `GET /api/posts/studies/{study-id} + + ✅ condition 설명: + - `page`: 조회할 페이지 번호 (0부터 시작) + + - `pageSize`: 한 페이지에 표시할 게시글 수 + + - `category`: 게시글 카테고리 (예: NOTICE, FREE) + + - `content`: 게시글 내용 검색 키워드 + + - `attributeName`: 정렬 대상 속성 (예: commentCount, createDateTime) + + - `sort`: 정렬 방향 (ASC 또는 DESC) + + ✅ 응답 예시: + ```json + { + "data": { + "noticePostInfos": [ + { + "postId": 3, + "category": "공지", + "title": "스터디 일정 공지", + "createdAt": "2025-07-21" + } + ], + "postInfos": [ + { + "id": 12, + "title": "모든 국민은 직업선택의 자유를 가진다.", + "category": "NOTICE", + "content": "국회는 의원의 자격을 심사하며, 의원을 징계할 있다.", + "createdDate": "2025-07-10T14:32:00", + "commentCount": 3 + } + ], + "totalPage": 3, + "totalElementCount": 12, + "hasNext": true + } + } + ``` + """ + ) + ApiResponse findPagingInfo(@Parameter(description = "스터디 ID", example = "1") Long studyId, + StudyPostPagingCondition condition); + + @Operation( + summary = "게시글 상세 조회", + description = """ + 특정 게시글의 상세 정보를 조회합니다. + + ✅ 요청 예시: + `GET /api/posts/{post-id}` + + ✅ 응답 예시: + ```json + { + "data": { + "postInfo": { + "postId": 15, + "title": "스터디에 참여해주세요", + "category": "NOTICE", + "content": "매주 월요일 정기모임 진행합니다.", + "createdDate": "2025-07-21T15:32:00", + "commentCount": 3 + }, + "comments": [ + { + "userId": 3, + "nickname": "너굴코더", + "profileImageUrl": "https://cdn.example.com/profile.jpg", + "id": 100, + "content": "정말 좋은 정보 감사합니다!", + "createdAt": "2025-07-10T14:45:00" + }, + { + "userId": 4, + "nickname": "코딩곰", + "profileImageUrl": "https://cdn.example.com/codingbear.png", + "id": 101, + "content": "참석하겠습니다!", + "createdAt": "2025-07-10T15:12:00" + } + ], + "commentCount": 3 + } + } + ``` + """ + ) + ApiResponse findOne( + @Parameter(description = "게시글 ID", example = "15") Long postId + ); + + @Operation(summary = "게시글 수정", description = "기존 게시글을 수정합니다.") + ApiResponse update( + @Parameter(description = "게시글 ID", example = "15") Long postId, + StudyPostUpdateRequest request, + Principal userDetails + ); + + @Operation(summary = "게시글 삭제", description = "특정 게시글을 삭제합니다.") + ApiResponse delete(@Parameter(description = "게시글 ID", example = "15") Long postId, + @AuthenticationPrincipal Principal userDetails); } diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostListResponse.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostListResponse.java new file mode 100644 index 00000000..4bc7fa0c --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/StudyPostListResponse.java @@ -0,0 +1,31 @@ +package grep.neogul_coder.domain.studypost.controller.dto; + +import grep.neogul_coder.domain.studypost.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Getter +@Schema(description = "스터디 게시글 페이징 조회") +public class StudyPostListResponse { + + static class PostPagingInfo { + @Schema(description = "게시글 ID", example = "12") + private Long id; + + @Schema(description = "제목", example = "모든 국민은 직업선택의 자유를 가진다.") + private String title; + + @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") + private Category category; + + @Schema(description = "본문", example = "국회는 의원의 자격을 심사하며, 의원을 징계할 있다.") + private String content; + + @Schema(description = "작성일", example = "2025-07-10T14:32:00") + private LocalDateTime createdDate; + + @Schema(description = "댓글 수", example = "3") + private int commentCount; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostPagingCondition.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostPagingCondition.java new file mode 100644 index 00000000..97913137 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostPagingCondition.java @@ -0,0 +1,46 @@ +package grep.neogul_coder.domain.studypost.controller.dto.request; + +import grep.neogul_coder.domain.studypost.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@Getter +public class StudyPostPagingCondition { + + private int page; + + @Positive + private int pageSize; + + @Schema(example = "NOTICE", description = " 스터디 공지 타입") + private Category category; + + @Schema(example = "내용", description = "자바 내용") + private String content; + + @Schema(example = "commentCount, createDateTime", description = "생성일 정렬") + private String attributeName; + + @Schema(example = "ASC, DESC", description = "댓글순 정렬") + private String sort; + + private StudyPostPagingCondition() { + } + + public StudyPostPagingCondition(int page, int pageSize, Category category, + String content, String attributeName, String sort) { + this.page = page; + this.pageSize = pageSize; + this.category = category; + this.content = content; + this.attributeName = attributeName; + this.sort = sort; + } + + public Pageable toPageable() { + return PageRequest.of(page, pageSize); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostSaveRequest.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostSaveRequest.java new file mode 100644 index 00000000..194fd67c --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostSaveRequest.java @@ -0,0 +1,54 @@ +package grep.neogul_coder.domain.studypost.controller.dto.request; + +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.studypost.Category; +import grep.neogul_coder.domain.studypost.StudyPost; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Schema(description = "스터디 게시글 저장 요청 DTO") +public class StudyPostSaveRequest { + + @Schema(description = "3", example = "스터디 ID") + @NotNull + private long studyId; + + @Schema(description = "제목", example = "스터디 공지") + @NotBlank + private String title; + + @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") + @NotBlank + private Category category; + + @Schema(description = "내용", example = "오늘은 각자 공부한 내용에 대해 발표가 있는 날 입니다!") + @NotBlank + private String content; + + private StudyPostSaveRequest() { + } + + @Builder + private StudyPostSaveRequest(long studyId, String title, Category category, String content) { + this.studyId = studyId; + this.title = title; + this.category = category; + this.content = content; + } + + public StudyPost toEntity(Study study, long userId) { + StudyPost studyPost = StudyPost.builder() + .userId(userId) + .title(this.title) + .category(this.category) + .content(this.content) + .build(); + + studyPost.connectStudy(study); + return studyPost; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostUpdateRequest.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostUpdateRequest.java new file mode 100644 index 00000000..3759d3ce --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/request/StudyPostUpdateRequest.java @@ -0,0 +1,34 @@ +package grep.neogul_coder.domain.studypost.controller.dto.request; + +import grep.neogul_coder.domain.studypost.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Schema(description = "스터디 게시글 수정 요청 DTO") +public class StudyPostUpdateRequest { + + @Schema(description = "제목", example = "스터디 공지") + @NotBlank + private String title; + + @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") + @NotBlank + private Category category; + + @Schema(description = "내용", example = "오늘은 각자 공부한 내용에 대해 발표가 있는 날 입니다!") + @NotBlank + private String content; + + private StudyPostUpdateRequest() { + } + + @Builder + private StudyPostUpdateRequest(String title, Category category, String content) { + this.title = title; + this.category = category; + this.content = content; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/CommentInfo.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/CommentInfo.java new file mode 100644 index 00000000..47ea8a56 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/CommentInfo.java @@ -0,0 +1,42 @@ +package grep.neogul_coder.domain.studypost.controller.dto.response; + +import com.querydsl.core.annotations.QueryProjection; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +import java.time.LocalDateTime; + +@ToString +@Getter +public class CommentInfo { + + @Schema(description = "댓글 ID", example = "3") + private long userId; + + @Schema(description = "작성자 닉네임", example = "너굴코더") + private String nickname; + + @Schema(description = "작성자 프로필 이미지 URL", example = "https://cdn.example.com/profile.jpg") + private String profileImageUrl; + + @Schema(description = "댓글 ID", example = "100") + private long id; + + @Schema(description = "댓글 내용", example = "정말 좋은 정보 감사합니다!") + private String content; + + @Schema(description = "작성일", example = "2025-07-10T14:45:00") + private LocalDateTime createdAt; + + @QueryProjection + public CommentInfo(long userId, String nickname, String profileImageUrl, long id, + String content, LocalDateTime createdAt) { + this.userId = userId; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.id = id; + this.content = content; + this.createdAt = createdAt; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/NoticePostInfo.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/NoticePostInfo.java new file mode 100644 index 00000000..a57319a3 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/NoticePostInfo.java @@ -0,0 +1,33 @@ +package grep.neogul_coder.domain.studypost.controller.dto.response; + +import com.querydsl.core.annotations.QueryProjection; +import grep.neogul_coder.domain.studypost.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +public class NoticePostInfo { + + @Schema(example = "3", description = "게시글 ID") + private long postId; + + @Schema(example = "공지", description = "게시글 타입") + private String category; + + @Schema(example = "제목", description = "공지글 제목") + private String title; + + @Schema(example = "2025-07-21", description = "생성일") + private LocalDate 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(); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostInfo.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostInfo.java new file mode 100644 index 00000000..01a9c0be --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostInfo.java @@ -0,0 +1,51 @@ +package grep.neogul_coder.domain.studypost.controller.dto.response; + +import com.querydsl.core.annotations.QueryProjection; +import grep.neogul_coder.domain.studypost.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +import java.time.LocalDateTime; + +@ToString +@Getter +public class PostInfo { + + @Schema(example = "3", description = "작성자 식별자") + private long userId; + + @Schema(description = "작성자 닉네임", example = "너굴코더") + private String nickname; + + @Schema(description = "작성자 프로필 이미지 URL", example = "https://cdn.example.com/profile.jpg") + private String profileImageUrl; + + @Schema(description = "게시글 ID", example = "10") + private Long postId; + + @Schema(description = "제목", example = "모든 국민은 직업선택의 자유를 가진다.") + private String title; + + @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") + private Category category; + + @Schema(description = "본문", example = "국회는 의원의 자격을 심사하며, 의원을 징계할 있다.") + private String content; + + @Schema(description = "작성일", example = "2025-07-10T14:00:00") + private LocalDateTime createdDate; + + @QueryProjection + public PostInfo(long userId, String nickname, String profileImageUrl, Long postId, + String title, Category category, String content, LocalDateTime createdDate) { + this.userId = userId; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.postId = postId; + this.title = title; + this.category = category; + this.content = content; + this.createdDate = createdDate; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingInfo.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingInfo.java new file mode 100644 index 00000000..7b229d22 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingInfo.java @@ -0,0 +1,43 @@ +package grep.neogul_coder.domain.studypost.controller.dto.response; + +import com.querydsl.core.annotations.QueryProjection; +import grep.neogul_coder.domain.studypost.Category; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +import java.time.LocalDateTime; + +@ToString +@Getter +public class PostPagingInfo { + + @Schema(description = "게시글 ID", example = "12") + private Long id; + + @Schema(description = "제목", example = "모든 국민은 직업선택의 자유를 가진다.") + private String title; + + @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") + private Category category; + + @Schema(description = "본문", example = "국회는 의원의 자격을 심사하며, 의원을 징계할 있다.") + private String content; + + @Schema(description = "작성일", example = "2025-07-10T14:32:00") + private LocalDateTime createdDate; + + @Schema(description = "댓글 수", example = "3") + private long commentCount; + + @QueryProjection + public PostPagingInfo(Long id, String title, Category category, + String content, LocalDateTime createdDate, long commentCount) { + this.id = id; + this.title = title; + this.category = category; + this.content = content; + this.createdDate = createdDate; + this.commentCount = commentCount; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingResult.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingResult.java new file mode 100644 index 00000000..cec63504 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/PostPagingResult.java @@ -0,0 +1,34 @@ +package grep.neogul_coder.domain.studypost.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +public class PostPagingResult { + + @Schema(description = "최신 공지글") + private List noticePostInfos; + + @Schema(description = "게시글 페이징 리스트") + private List postInfos; + + @Schema(example = "3", description = "총 페이지수") + private long totalPage; + + @Schema(example = "3", description = "총 요소 개수") + private long totalElementCount; + + @Schema(example = "true", description = "다음 페이지 여부") + private boolean hasNext; + + public PostPagingResult(List noticePostInfos, Page page) { + this.noticePostInfos = noticePostInfos; + this.postInfos = page.getContent(); + this.totalPage = page.getTotalPages(); + this.totalElementCount = page.getTotalElements(); + this.hasNext = page.hasNext(); + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/StudyPostDetailResponse.java b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/StudyPostDetailResponse.java new file mode 100644 index 00000000..56957659 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/controller/dto/response/StudyPostDetailResponse.java @@ -0,0 +1,28 @@ +package grep.neogul_coder.domain.studypost.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.ToString; + +import java.util.List; + +@ToString +@Getter +@Schema(description = "스터디 게시글 상세 응답 DTO") +public class StudyPostDetailResponse { + + @Schema(description = "게시글 회원 정보") + private PostInfo postInfo; + + @Schema(description = "댓글 목록") + private List comments; + + @Schema(description = "댓글 수", example = "3") + private int commentCount; + + public StudyPostDetailResponse(PostInfo postInfo, List comments, int commentCount) { + this.postInfo = postInfo; + this.comments = comments; + this.commentCount = commentCount; + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostDetailResponse.java b/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostDetailResponse.java deleted file mode 100644 index 621cd160..00000000 --- a/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostDetailResponse.java +++ /dev/null @@ -1,40 +0,0 @@ -package grep.neogul_coder.domain.studypost.dto; - -import grep.neogul_coder.domain.comment.dto.CommentResponse; -import grep.neogul_coder.domain.studypost.Category; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; -import java.util.List; -import lombok.Getter; - -@Getter -@Schema(description = "스터디 게시글 상세 응답 DTO") -public class StudyPostDetailResponse { - - @Schema(description = "게시글 ID", example = "10") - private Long id; - - @Schema(description = "제목", example = "모든 국민은 직업선택의 자유를 가진다.") - private String title; - - @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") - private Category category; - - @Schema(description = "본문", example = "국회는 의원의 자격을 심사하며, 의원을 징계할 있다.") - private String content; - - @Schema(description = "작성일", example = "2025-07-10T14:00:00") - private LocalDateTime createdDate; - - @Schema(description = "작성자 닉네임", example = "너굴코더") - private String nickname; - - @Schema(description = "작성자 프로필 이미지 URL", example = "https://cdn.example.com/profile.jpg") - private String profileImageUrl; - - @Schema(description = "댓글 수", example = "3") - private int commentCount; - - @Schema(description = "댓글 목록") - private List comments; -} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostListResponse.java b/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostListResponse.java deleted file mode 100644 index 2b79dbea..00000000 --- a/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostListResponse.java +++ /dev/null @@ -1,29 +0,0 @@ -package grep.neogul_coder.domain.studypost.dto; - -import grep.neogul_coder.domain.studypost.Category; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; -import lombok.Getter; - -@Getter -@Schema(description = "스터디 게시글 목록 응답 DTO") -public class StudyPostListResponse { - - @Schema(description = "게시글 ID", example = "12") - private Long id; - - @Schema(description = "제목", example = "모든 국민은 직업선택의 자유를 가진다.") - private String title; - - @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") - private Category category; - - @Schema(description = "본문", example = "국회는 의원의 자격을 심사하며, 의원을 징계할 있다.") - private String content; - - @Schema(description = "작성일", example = "2025-07-10T14:32:00") - private LocalDateTime createdDate; - - @Schema(description = "댓글 수", example = "3") - private int commentCount; -} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostRequest.java b/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostRequest.java deleted file mode 100644 index ac3ec449..00000000 --- a/src/main/java/grep/neogul_coder/domain/studypost/dto/StudyPostRequest.java +++ /dev/null @@ -1,22 +0,0 @@ -package grep.neogul_coder.domain.studypost.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import lombok.Getter; - -@Getter -@Schema(description = "스터디 게시글 저장/수정 요청 DTO") -public class StudyPostRequest { - - @Schema(description = "제목", example = "스터디 공지") - @NotBlank - private String title; - - @Schema(description = "카테고리: NOTICE(공지), FREE(자유)", example = "NOTICE") - @NotBlank - private String category; - - @Schema(description = "내용", example = "오늘은 각자 공부한 내용에 대해 발표가 있는 날 입니다!") - @NotBlank - private String content; -} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java b/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java new file mode 100644 index 00000000..6fa76561 --- /dev/null +++ b/src/main/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepository.java @@ -0,0 +1,176 @@ +package grep.neogul_coder.domain.studypost.repository; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import grep.neogul_coder.domain.studypost.Category; +import grep.neogul_coder.domain.studypost.QStudyPost; +import grep.neogul_coder.domain.studypost.StudyPost; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostPagingCondition; +import grep.neogul_coder.domain.studypost.controller.dto.response.*; +import grep.neogul_coder.global.exception.validation.ValidationException; +import jakarta.persistence.EntityManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; + +import static grep.neogul_coder.domain.studypost.Category.NOTICE; +import static grep.neogul_coder.domain.studypost.StudyPostErrorCode.NOT_VALID_CONDITION; +import static grep.neogul_coder.domain.studypost.comment.QStudyPostComment.studyPostComment; +import static grep.neogul_coder.domain.users.entity.QUser.user; + +@Repository +public class StudyPostQueryRepository { + + private final EntityManager em; + private final JPAQueryFactory queryFactory; + + public static final int NOTICE_POST_LIMIT = 2; + + private final QStudyPost studyPost = QStudyPost.studyPost; + + public StudyPostQueryRepository(EntityManager em) { + this.em = em; + this.queryFactory = new JPAQueryFactory(em); + } + + public PostInfo findPostWriterInfo(Long postId) { + return queryFactory.select( + new QPostInfo( + user.id, + user.nickname, + user.profileImageUrl, + studyPost.id.as("postId"), + studyPost.title, + studyPost.category, + studyPost.content, + studyPost.createdDate + ) + ) + .from(studyPost) + .join(user).on(studyPost.userId.eq(user.id)) + .where( + studyPost.id.eq(postId), + studyPost.activated.isTrue() + ) + .fetchOne(); + } + + public Optional findByIdAndUserId(Long postId, long userId) { + StudyPost findStudyPost = queryFactory.selectFrom(studyPost) + .where( + studyPost.id.eq(postId), + studyPost.userId.eq(userId), + studyPost.activated.isTrue() + ) + .fetchOne(); + + return Optional.ofNullable(findStudyPost); + } + + public Page findPagingFilteredBy(StudyPostPagingCondition condition, Long studyId) { + Pageable pageable = condition.toPageable(); + + JPAQuery query = queryFactory.select( + new QPostPagingInfo( + studyPost.id, + studyPost.title, + studyPost.category, + studyPost.content, + studyPost.createdDate, + studyPostComment.countDistinct() + ) + ) + .from(studyPost) + .leftJoin(studyPostComment).on(studyPost.id.eq(studyPostComment.postId)) + .where( + studyPost.activated.isTrue(), + studyPost.study.id.eq(studyId), + likeContent(condition.getContent()), + equalsCategory(condition.getCategory()) + ) + .groupBy(studyPost.id) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()); + + OrderSpecifier orderSpecifier = resolveOrderSpecifier(condition.getAttributeName(), condition.getSort()); + if (orderSpecifier != null) { + query.orderBy(orderSpecifier); + } + + Long count = queryFactory.select(studyPost.count()) + .from(studyPost) + .where( + studyPost.activated.isTrue(), + studyPost.study.id.eq(studyId), + likeContent(condition.getContent()), + equalsCategory(condition.getCategory()) + ) + .fetchOne(); + + return new PageImpl<>(query.fetch(), pageable, count == null ? 0 : count); + } + + public List findLatestNoticeInfoBy(Long studyId) { + return queryFactory.select( + new QNoticePostInfo( + studyPost.id, + studyPost.category, + studyPost.title, + studyPost.createdDate + ) + ) + .from(studyPost) + .where( + studyPost.activated.isTrue(), + studyPost.study.id.eq(studyId), + studyPost.category.eq(NOTICE) + ) + .orderBy(studyPost.createdDate.desc()) + .limit(NOTICE_POST_LIMIT) + .fetch(); + } + + private OrderSpecifier resolveOrderSpecifier(String attributeName, String direction) { + if (attributeName == null || direction == null) { + return null; + } + + boolean isAsc = "ASC".equals(direction); + + if (attributeName.equalsIgnoreCase("commentCount")) { + NumberExpression commentCount = studyPostComment.id.countDistinct(); + return isAsc ? commentCount.asc() : commentCount.desc(); + } + + if (attributeName.equalsIgnoreCase("createDateTime")) { + return isAsc ? studyPost.createdDate.asc() : studyPost.createdDate.desc(); + } + + throw new ValidationException(NOT_VALID_CONDITION); + } + + private BooleanBuilder likeContent(String content) { + return nullSafeBuilder(() -> studyPost.content.contains(content)); + } + + private BooleanBuilder equalsCategory(Category category) { + return nullSafeBuilder(() -> studyPost.category.eq(category)); + } + + private BooleanBuilder nullSafeBuilder(Supplier supplier) { + try { + return new BooleanBuilder(supplier.get()); + } catch (Exception e) { + return new BooleanBuilder(); + } + } +} diff --git a/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java b/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java index 2c250a83..815dd856 100644 --- a/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java +++ b/src/main/java/grep/neogul_coder/domain/studypost/service/StudyPostService.java @@ -1,21 +1,83 @@ package grep.neogul_coder.domain.studypost.service; +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.StudyMember; +import grep.neogul_coder.domain.study.repository.StudyMemberQueryRepository; import grep.neogul_coder.domain.studypost.StudyPost; -import grep.neogul_coder.domain.studypost.exception.PostNotFoundException; -import grep.neogul_coder.domain.studypost.exception.code.PostErrorCode; +import grep.neogul_coder.domain.studypost.comment.repository.StudyPostCommentQueryRepository; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostPagingCondition; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; +import grep.neogul_coder.domain.studypost.controller.dto.response.*; +import grep.neogul_coder.domain.studypost.repository.StudyPostQueryRepository; import grep.neogul_coder.domain.studypost.repository.StudyPostRepository; +import grep.neogul_coder.global.exception.business.NotFoundException; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +import static grep.neogul_coder.domain.studypost.StudyPostErrorCode.NOT_FOUND_POST; +import static grep.neogul_coder.domain.studypost.StudyPostErrorCode.NOT_JOINED_STUDY_USER; + +@Transactional(readOnly = true) @Service @RequiredArgsConstructor public class StudyPostService { - private final StudyPostRepository StudyPostRepository; + private final StudyMemberQueryRepository studyQueryRepository; + + private final StudyPostRepository studyPostRepository; + private final StudyPostQueryRepository studyPostQueryRepository; + + private final StudyPostCommentQueryRepository commentQueryRepository; + + public StudyPostDetailResponse findOne(Long postId) { + PostInfo postInfo = studyPostQueryRepository.findPostWriterInfo(postId); + List commentInfos = commentQueryRepository.findWriterInfosByPostId(postId); + return new StudyPostDetailResponse(postInfo, commentInfos, commentInfos.size()); + } + + public PostPagingResult findPagingInfo(StudyPostPagingCondition condition, Long studyId) { + Page pages = studyPostQueryRepository.findPagingFilteredBy(condition, studyId); + List noticeInfos = studyPostQueryRepository.findLatestNoticeInfoBy(studyId); + return new PostPagingResult(noticeInfos, pages); + } - public StudyPost findById(Long id) { - return StudyPostRepository.findById(id).orElseThrow(() -> new PostNotFoundException( - PostErrorCode.POST_NOT_FOUND)); + @Transactional + public long create(StudyPostSaveRequest request, long userId) { + List myStudies = studyQueryRepository.findAllFetchStudyByUserId(userId); + Study study = extractTargetStudyById(myStudies, request.getStudyId()); + return studyPostRepository.save(request.toEntity(study, userId)).getId(); } + @Transactional + public void update(StudyPostUpdateRequest request, Long postId, long userId) { + StudyPost studyPost = studyPostQueryRepository.findByIdAndUserId(postId, userId) + .orElseThrow(() -> new NotFoundException(NOT_FOUND_POST)); + + studyPost.update( + request.getCategory(), + request.getTitle(), + request.getContent() + ); + } + + @Transactional + public void delete(Long postId, long userId) { + StudyPost studyPost = studyPostQueryRepository.findByIdAndUserId(postId, userId) + .orElseThrow(() -> new NotFoundException(NOT_FOUND_POST)); + + studyPost.delete(); + } + + private Study extractTargetStudyById(List studyMembers, long studyId) { + return studyMembers.stream() + .map(StudyMember::getStudy) + .filter(study -> studyId == study.getId()) + .findFirst() + .orElseThrow(() -> new NotFoundException(NOT_JOINED_STUDY_USER)); + } } diff --git a/src/main/java/grep/neogul_coder/domain/users/service/UserService.java b/src/main/java/grep/neogul_coder/domain/users/service/UserService.java index 53f049ca..644e0b77 100644 --- a/src/main/java/grep/neogul_coder/domain/users/service/UserService.java +++ b/src/main/java/grep/neogul_coder/domain/users/service/UserService.java @@ -100,7 +100,7 @@ public void updateProfile(Long userId, String nickname, MultipartFile profileIma FileUploadResponse response = isProductionEnvironment() ? gcpFileUploader.upload(profileImage, userId, FileUsageType.PROFILE, userId) : localFileUploader.upload(profileImage, userId, FileUsageType.PROFILE, userId); - uploadedImageUrl = response.fileUrl(); + uploadedImageUrl = response.getFileUrl(); } else { uploadedImageUrl = user.getProfileImageUrl(); } diff --git a/src/main/java/grep/neogul_coder/global/auth/entity/RefreshToken.java b/src/main/java/grep/neogul_coder/global/auth/entity/RefreshToken.java index 0d36c8d0..600e5090 100644 --- a/src/main/java/grep/neogul_coder/global/auth/entity/RefreshToken.java +++ b/src/main/java/grep/neogul_coder/global/auth/entity/RefreshToken.java @@ -12,6 +12,9 @@ public class RefreshToken { private String token = UUID.randomUUID().toString(); private Long ttl = 3600 * 24 * 7L; + public RefreshToken() { + } + public RefreshToken(String atId){ this.atId = atId; } diff --git a/src/main/java/grep/neogul_coder/global/config/WebSocketConfig.java b/src/main/java/grep/neogul_coder/global/config/WebSocketConfig.java new file mode 100644 index 00000000..54d16ee2 --- /dev/null +++ b/src/main/java/grep/neogul_coder/global/config/WebSocketConfig.java @@ -0,0 +1,27 @@ +package grep.neogul_coder.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.*; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + // 클라이언트가 메시지를 받을 수 있는 경로(prefix) + registry.enableSimpleBroker("/sub"); + + // 클라이언트가 메시지를 보낼 때 사용할 prefix + registry.setApplicationDestinationPrefixes("/pub"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // 클라이언트가 WebSocket에 연결할 endpoint + registry.addEndpoint("/ws-stomp") + .setAllowedOriginPatterns("*") // CORS 허용 + .withSockJS(); // SockJS 지원 + } +} diff --git a/src/main/java/grep/neogul_coder/global/utils/upload/AbstractFileManager.java b/src/main/java/grep/neogul_coder/global/utils/upload/AbstractFileManager.java index 88dbf8f4..a648a494 100644 --- a/src/main/java/grep/neogul_coder/global/utils/upload/AbstractFileManager.java +++ b/src/main/java/grep/neogul_coder/global/utils/upload/AbstractFileManager.java @@ -48,15 +48,15 @@ public FileUploadResponse upload(MultipartFile file, Long uploaderId, FileUsageT uploadFile(file, buildFullPath(savePath, renameFileName)); // 실제 파일 업로드(구현체에서 구현) - return new FileUploadResponse( - originFileName, - renameFileName, - usageType, - fileUrl, - savePath, - uploaderId, - usageRefId - ); + return FileUploadResponse.builder() + .originFileName(originFileName) + .renameFileName(renameFileName) + .usageType(usageType) + .savePath(savePath) + .fileUrl(fileUrl) + .uploaderId(uploaderId) + .usageRefId(usageRefId) + .build(); } // 실제 파일 업로드를 수행 diff --git a/src/main/java/grep/neogul_coder/global/utils/upload/FileUploadResponse.java b/src/main/java/grep/neogul_coder/global/utils/upload/FileUploadResponse.java index 76e10463..82f0674f 100644 --- a/src/main/java/grep/neogul_coder/global/utils/upload/FileUploadResponse.java +++ b/src/main/java/grep/neogul_coder/global/utils/upload/FileUploadResponse.java @@ -1,13 +1,26 @@ package grep.neogul_coder.global.utils.upload; -public record FileUploadResponse( - String originFileName, // 원본 파일명 - String renameFileName, // UUID로 변경된 파일명 - FileUsageType usageType, // 파일 사용 목적 - String savePath, // 저장 경로 - String fileUrl, // 전체 URL - Long uploaderId, // 업로더 ID - Long usageRefId // 파일이 참조되는 도메인 ID -) { +import lombok.Builder; +import lombok.Getter; +@Getter +public class FileUploadResponse { + private String originFileName; // 원본 파일명 + private String renameFileName; // UUID로 변경된 파일명 + private FileUsageType usageType; // 파일 사용 목적 + private String savePath; // 저장 경로 + private String fileUrl; // 전체 URL + private Long uploaderId; // 업로더 ID + private Long usageRefId; // 파일이 참조되는 도메인 ID + + @Builder + public FileUploadResponse(String originFileName, String renameFileName, FileUsageType usageType, String savePath, String fileUrl, Long uploaderId, Long usageRefId) { + this.originFileName = originFileName; + this.renameFileName = renameFileName; + this.usageType = usageType; + this.savePath = savePath; + this.fileUrl = fileUrl; + this.uploaderId = uploaderId; + this.usageRefId = usageRefId; + } } diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 2a2695cf..9d7e7a5e 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -105,11 +105,11 @@ INSERT INTO pr_template (user_id, introduction, location) VALUES (2, '실용적 INSERT INTO pr_template (user_id, introduction, location) VALUES (4, '초심을 잃지 않는 프론트엔드 개발자입니다. Vue, React 기반 프로젝트 경험이 있으며, UI/UX에 대한 관심도 많습니다.', '대전시 유성구'); INSERT INTO pr_template (user_id, introduction, location) VALUES (5, '문제를 해결하는 것이 즐거운 백엔드 개발자입니다. JPA, QueryDSL 기반의 안정적인 데이터 처리와 아키텍처 설계에 관심이 있습니다.', '인천시 연수구'); -INSERT INTO link (user_id, pr_url, url_name) VALUES (1, 'https://github.com/yeongho', 'GitHub 포트폴리오'); -INSERT INTO link (user_id, pr_url, url_name) VALUES (2, 'https://velog.io/@jiweon01', '기술 블로그 (Velog)'); -INSERT INTO link (user_id, pr_url, url_name) VALUES (5, 'https://notion.so/dev-profile', '기술 이력서 (Notion)'); -INSERT INTO link (user_id, pr_url, url_name) VALUES (5, 'https://toss.im/team/gimgim', '팀 프로젝트 소개'); -INSERT INTO link (user_id, pr_url, url_name) VALUES (1, 'https://linkedin.com/in/eungyeong', 'LinkedIn 프로필'); +INSERT INTO link (user_id, pr_url, url_name, activated) VALUES (1, 'https://github.com/yeongho', 'GitHub 포트폴리오', true); +INSERT INTO link (user_id, pr_url, url_name, activated) VALUES (2, 'https://velog.io/@jiweon01', '기술 블로그 (Velog)', true); +INSERT INTO link (user_id, pr_url, url_name, activated) VALUES (5, 'https://notion.so/dev-profile', '기술 이력서 (Notion)', true); +INSERT INTO link (user_id, pr_url, url_name, activated) VALUES (5, 'https://toss.im/team/gimgim', '팀 프로젝트 소개', true); +INSERT INTO link (user_id, pr_url, url_name, activated) VALUES (1, 'https://linkedin.com/in/eungyeong', 'LinkedIn 프로필', true); INSERT INTO attendance (study_id, user_id, attendance_date) VALUES (1, 3, '2025-07-01'); INSERT INTO attendance (study_id, user_id, attendance_date) VALUES (1, 3, '2025-07-02'); @@ -120,10 +120,10 @@ INSERT INTO attendance (study_id, user_id, attendance_date) VALUES (3, 2, '2025- INSERT INTO group_chat_room (study_id) VALUES (1); INSERT INTO group_chat_room (study_id) VALUES (2); -INSERT INTO group_chat_message (room_id, user_id, content) VALUES (1, 1, '안녕하세요! 스터디 언제 시작하나요?'); -INSERT INTO group_chat_message (room_id, user_id, content) VALUES (1, 2, '오늘 저녁 8시에 시작해요!'); -INSERT INTO group_chat_message (room_id, user_id, content) VALUES (2, 3, '파일 올렸어요. 확인 부탁드려요.'); -INSERT INTO group_chat_message (room_id, user_id, content) VALUES (2, 1, '네 확인했어요. 감사합니다!'); +INSERT INTO group_chat_message (room_id, user_id, message) VALUES (1, 1, '안녕하세요! 스터디 언제 시작하나요?'); +INSERT INTO group_chat_message (room_id, user_id, message) VALUES (1, 2, '오늘 저녁 8시에 시작해요!'); +INSERT INTO group_chat_message (room_id, user_id, message) VALUES (2, 3, '파일 올렸어요. 확인 부탁드려요.'); +INSERT INTO group_chat_message (room_id, user_id, message) VALUES (2, 1, '네 확인했어요. 감사합니다!'); INSERT INTO review (study_id, write_user_id, target_user_id, content, created_date) VALUES (1, 1, 1, '열심히 참여하셨어요.', '2025-07-10 10:00:00'); INSERT INTO review (study_id, write_user_id, target_user_id, content, created_date) VALUES (1, 2, 2, '피드백이 빠르고 정확했어요. 하지만 지각을 자주하십니다', '2025-07-11 09:30:00'); diff --git a/src/test/java/grep/neogul_coder/domain/attendance/service/AttendanceServiceTest.java b/src/test/java/grep/neogul_coder/domain/attendance/service/AttendanceServiceTest.java new file mode 100644 index 00000000..425c07e8 --- /dev/null +++ b/src/test/java/grep/neogul_coder/domain/attendance/service/AttendanceServiceTest.java @@ -0,0 +1,120 @@ +package grep.neogul_coder.domain.attendance.service; + +import grep.neogul_coder.domain.IntegrationTestSupport; +import grep.neogul_coder.domain.attendance.Attendance; +import grep.neogul_coder.domain.attendance.controller.dto.response.AttendanceInfoResponse; +import grep.neogul_coder.domain.attendance.repository.AttendanceRepository; +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.StudyMember; +import grep.neogul_coder.domain.study.repository.StudyMemberRepository; +import grep.neogul_coder.domain.study.repository.StudyRepository; +import grep.neogul_coder.domain.users.entity.User; +import grep.neogul_coder.domain.users.repository.UserRepository; +import grep.neogul_coder.global.exception.business.BusinessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static grep.neogul_coder.domain.attendance.exception.code.AttendanceErrorCode.ATTENDANCE_ALREADY_CHECKED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AttendanceServiceTest extends IntegrationTestSupport { + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudyRepository studyRepository; + + @Autowired + private StudyMemberRepository studyMemberRepository; + + @Autowired + private AttendanceService attendanceService; + + @Autowired + private AttendanceRepository attendanceRepository; + + private Long userId; + private Long studyId; + + @BeforeEach + void init() { + User user = createUser("test1"); + userRepository.save(user); + userId = user.getId(); + + Study study = createStudy("스터디", LocalDateTime.parse("2025-07-25T20:20:20"), LocalDateTime.parse("2025-07-28T20:20:20")); + studyRepository.save(study); + studyId = study.getId(); + + StudyMember studyMember = createStudyMember(study, userId); + studyMemberRepository.save(studyMember); + } + + @DisplayName("출석을 조회합니다.") + @Test + void getAttendances() { + // given + Attendance attendance = Attendance.create(studyId, userId); + attendanceRepository.save(attendance); + + // when + AttendanceInfoResponse response = attendanceService.getAttendances(studyId, userId); + + // then + assertThat(response.getAttendances().getFirst().getStudyId()).isEqualTo(studyId); + } + + @DisplayName("스터디에 출석 체크를 합니다.") + @Test + void createAttendance() { + // given + Attendance attendance = Attendance.create(studyId, userId); + + // when + attendanceRepository.save(attendance); + + // then + assertThat(attendance.getAttendanceDate().toLocalDate()).isEqualTo(LocalDate.now()); + } + + @DisplayName("스터디에 이미 출석한 경우 예외가 발생합니다.") + @Test + void createAttendanceFail() { + // given + Attendance attendance = Attendance.create(studyId, userId); + attendanceRepository.save(attendance); + + // when then + assertThatThrownBy(() -> + attendanceService.createAttendance(studyId, userId)) + .isInstanceOf(BusinessException.class).hasMessage(ATTENDANCE_ALREADY_CHECKED.getMessage()); + } + + private static User createUser(String nickname) { + return User.builder() + .nickname(nickname) + .build(); + } + + private static Study createStudy(String name, LocalDateTime startDate, LocalDateTime endDate) { + return Study.builder() + .name(name) + .startDate(startDate) + .endDate(endDate) + .build(); + } + + private StudyMember createStudyMember(Study study, long userId) { + return StudyMember.builder() + .study(study) + .userId(userId) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostCommentQueryRepositoryTest.java b/src/test/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostStudyPostCommentQueryRepositoryTest.java similarity index 100% rename from src/test/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostCommentQueryRepositoryTest.java rename to src/test/java/grep/neogul_coder/domain/recruitment/post/repository/RecruitmentPostStudyPostCommentQueryRepositoryTest.java diff --git a/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java b/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java index 168f0c32..9684bcf3 100644 --- a/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java +++ b/src/test/java/grep/neogul_coder/domain/study/service/StudyServiceTest.java @@ -14,13 +14,17 @@ import grep.neogul_coder.domain.users.entity.User; import grep.neogul_coder.domain.users.repository.UserRepository; import grep.neogul_coder.global.exception.business.BusinessException; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.time.LocalDateTime; import java.util.List; @@ -32,6 +36,9 @@ class StudyServiceTest extends IntegrationTestSupport { + @Autowired + private EntityManager em; + @Autowired private UserRepository userRepository; @@ -54,9 +61,30 @@ void init() { userId = user1.getId(); } + @DisplayName("가입한 스터디 목록을 페이징 조회합니다.") + @Test + void getStudies() { + // given + Pageable pageable = PageRequest.of(0, 12); + + Study study = createStudy("스터디", Category.IT, 3, StudyType.OFFLINE, "서울", LocalDateTime.of(2025, 7, 18, 0, 0, 0), + LocalDateTime.of(2025, 7, 28, 0, 0, 0), "스터디입니다.", "http://localhost:8083/image.url"); + studyRepository.save(study); + Long studyId = study.getId(); + + StudyMember studyMember = createStudyMember(study, userId, LEADER); + studyMemberRepository.save(studyMember); + + // when + StudyItemPagingResponse response = studyService.getMyStudiesPaging(pageable, userId); + + // then + assertThat(response.getStudies().getFirst().getName()).isEqualTo("스터디"); + } + @DisplayName("스터디를 생성합니다.") @Test - void createStudy() { + void createStudy() throws IOException { // given StudyCreateRequest request = StudyCreateRequest.builder() .name("스터디") @@ -69,41 +97,23 @@ void createStudy() { .introduction("스터디입니다.") .imageUrl("http://localhost:8083/image.url") .build(); + MultipartFile image = null; // when - Long id = studyService.createStudy(request, userId); + Long id = studyService.createStudy(request, userId, image); + em.flush(); + em.clear(); // then Study findStudy = studyRepository.findByIdAndActivatedTrue(id).orElseThrow(); assertThat(findStudy.getName()).isEqualTo("스터디"); } - @DisplayName("가입한 스터디 목록을 페이징 조회합니다.") - @Test - void getStudies() { - // given - Pageable pageable = PageRequest.of(0, 12); - - Study study = createStudy("스터디", Category.IT, 3, StudyType.OFFLINE, "서울", LocalDateTime.of(2025, 7, 18, 0, 0, 0), - LocalDateTime.of(2025, 7, 28, 0, 0, 0), "스터디입니다.", "http://localhost:8083/image.url"); - studyRepository.save(study); - Long studyId = study.getId(); - - StudyMember studyMember = createStudyMember(study, userId, LEADER); - studyMemberRepository.save(studyMember); - - // when - StudyItemPagingResponse response = studyService.getMyStudiesPaging(pageable, userId); - - // then - assertThat(response.getStudies().getFirst().getName()).isEqualTo("스터디"); - } - @DisplayName("스터디장이 스터디를 수정합니다.") @Test - void updateStudy() { + void updateStudy() throws IOException { // given - Study study = createStudy("스터디", Category.IT, 3, StudyType.OFFLINE, "서울", LocalDateTime.of(2025, 7, 18, 0, 0, 0), + Study study = createStudy("스터디", Category.IT, 3, StudyType.OFFLINE, "서울", LocalDateTime.of(2026, 7, 18, 0, 0, 0), LocalDateTime.of(2025, 7, 28, 0, 0, 0), "스터디입니다.", "http://localhost:8083/image.url"); studyRepository.save(study); Long studyId = study.getId(); @@ -117,13 +127,15 @@ void updateStudy() { .capacity(8) .studyType(StudyType.OFFLINE) .location("서울") - .startDate(LocalDateTime.now()) + .startDate(LocalDateTime.of(2026, 7, 20, 0, 0, 0)) .introduction("Updated") - .imageUrl("http://localhost:8083/image.url") .build(); + MultipartFile image = null; // when - studyService.updateStudy(studyId, request, userId); + studyService.updateStudy(studyId, request, userId, image); + em.flush(); + em.clear(); // then Study findStudy = studyRepository.findByIdAndActivatedTrue(studyId).orElseThrow(); @@ -152,10 +164,11 @@ void updateStudyFail() { .introduction("Updated") .imageUrl("http://localhost:8083/image.url") .build(); + MultipartFile image = null; // when then assertThatThrownBy(() -> - studyService.updateStudy(studyId, request, userId)) + studyService.updateStudy(studyId, request, userId, image)) .isInstanceOf(BusinessException.class).hasMessage(NOT_STUDY_LEADER.getMessage()); } @@ -173,9 +186,13 @@ void deleteStudy() { // when studyService.deleteStudy(studyId, userId); + em.flush(); + em.clear(); + + Study deletedStudy = studyRepository.findById(studyId).orElseThrow(); // then - assertThat(study.getActivated()).isFalse(); + assertThat(deletedStudy.getActivated()).isFalse(); } private static User createUser(String nickname) { diff --git a/src/test/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepositoryTest.java b/src/test/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepositoryTest.java new file mode 100644 index 00000000..8394ecb6 --- /dev/null +++ b/src/test/java/grep/neogul_coder/domain/studypost/repository/StudyPostQueryRepositoryTest.java @@ -0,0 +1,164 @@ +package grep.neogul_coder.domain.studypost.repository; + +import grep.neogul_coder.domain.IntegrationTestSupport; +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.StudyMember; +import grep.neogul_coder.domain.study.repository.StudyMemberRepository; +import grep.neogul_coder.domain.study.repository.StudyRepository; +import grep.neogul_coder.domain.studypost.Category; +import grep.neogul_coder.domain.studypost.StudyPost; +import grep.neogul_coder.domain.studypost.comment.StudyPostComment; +import grep.neogul_coder.domain.studypost.comment.repository.StudyCommentRepository; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostPagingCondition; +import grep.neogul_coder.domain.studypost.controller.dto.response.NoticePostInfo; +import grep.neogul_coder.domain.studypost.controller.dto.response.PostPagingInfo; +import grep.neogul_coder.domain.users.entity.User; +import grep.neogul_coder.domain.users.repository.UserRepository; +import jakarta.persistence.EntityManager; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; + +import java.util.List; + +import static grep.neogul_coder.domain.studypost.Category.FREE; +import static grep.neogul_coder.domain.studypost.Category.NOTICE; +import static org.assertj.core.api.Assertions.assertThat; + +class StudyPostQueryRepositoryTest extends IntegrationTestSupport { + + @Autowired + private EntityManager em; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudyRepository studyRepository; + + @Autowired + private StudyMemberRepository studyMemberRepository; + + @Autowired + private StudyPostRepository studyPostRepository; + + @Autowired + private StudyPostQueryRepository studyPostQueryRepository; + + @Autowired + private StudyCommentRepository studycommentRepository; + + @DisplayName("스터디 게시글을 페이징 조회 합니다.") + @Test + void findPagingFilteredBy() { + //given + User user = createUser("테스터"); + userRepository.save(user); + + Study study = createStudy("자바 스터디"); + studyRepository.save(study); + + StudyPost post1 = createStudyPost(study, user.getId(), "제목1", NOTICE, "Like 내용1"); + StudyPost post2 = createStudyPost(study, user.getId(), "제목2", FREE, "Like 내용2"); + + List posts = List.of( + post1, post2, + createStudyPost(study, user.getId(), "제목3", FREE, "내용3"), + createStudyPost(study, user.getId(), "제목4", NOTICE, "내용4"), + createStudyPost(study, user.getId(), "제목5", FREE, "내용5") + ); + studyPostRepository.saveAll(posts); + + List comments = List.of( + createPostComment(post1.getId(), user.getId(), "댓글1"), + createPostComment(post2.getId(), user.getId(), "댓글2"), + createPostComment(post2.getId(), user.getId(), "댓글3") + ); + studycommentRepository.saveAll(comments); + + //when + StudyPostPagingCondition condition = new StudyPostPagingCondition(0, 2, FREE, "Like", "createDateTime", "DESC"); + Page page = studyPostQueryRepository.findPagingFilteredBy(condition, study.getId()); + List response = page.getContent(); + // System.out.println("response = " + response); + // System.out.println("page.getTotalPages() = " + page.getTotalPages()); + // System.out.println("page.getTotalElements() = " + page.getTotalElements()); + + //then + assertThat(response).hasSize(1); + assertThat(response) + .extracting("title", "commentCount") + .containsExactly( + Tuple.tuple("제목2", 2L) + ); + } + + @DisplayName("가장 최근 공지글을 조회 합니다.") + @Test + void findLatestNoticeInfoBy() { + //given + User user = createUser("테스터"); + userRepository.save(user); + + Study study = createStudy("자바 스터디"); + studyRepository.save(study); + + List posts = List.of( + createStudyPost(study, user.getId(), "공지글1", NOTICE, "내용1"), + createStudyPost(study, user.getId(), "자유글2", FREE, "내용2"), + createStudyPost(study, user.getId(), "공지글2", NOTICE, "내용3") + ); + studyPostRepository.saveAll(posts); + + //when + List result = studyPostQueryRepository.findLatestNoticeInfoBy(study.getId()); + + //then + assertThat(result).hasSize(2) + .extracting("title") + .containsExactlyInAnyOrder("공지글1", "공지글2"); + } + + private User createUser(String nickname) { + return User.builder() + .nickname(nickname) + .password("tempPassword") + .build(); + } + + private Study createStudy(String name) { + return Study.builder() + .name(name) + .build(); + } + + + private StudyMember createStudyMember(Study study, long userId) { + return StudyMember.builder() + .study(study) + .userId(userId) + .build(); + } + + private StudyPost createStudyPost(Study study, long userId, String title, Category category, String content) { + StudyPost studyPost = StudyPost.builder() + .userId(userId) + .title(title) + .category(category) + .content(content) + .build(); + + studyPost.connectStudy(study); + return studyPost; + } + + private StudyPostComment createPostComment(long postId, long userId, String content) { + return StudyPostComment.builder() + .postId(postId) + .userId(userId) + .content(content) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/grep/neogul_coder/domain/studypost/service/StudyPostServiceTest.java b/src/test/java/grep/neogul_coder/domain/studypost/service/StudyPostServiceTest.java new file mode 100644 index 00000000..a4135664 --- /dev/null +++ b/src/test/java/grep/neogul_coder/domain/studypost/service/StudyPostServiceTest.java @@ -0,0 +1,242 @@ +package grep.neogul_coder.domain.studypost.service; + +import grep.neogul_coder.domain.IntegrationTestSupport; +import grep.neogul_coder.domain.study.Study; +import grep.neogul_coder.domain.study.StudyMember; +import grep.neogul_coder.domain.study.repository.StudyMemberRepository; +import grep.neogul_coder.domain.study.repository.StudyRepository; +import grep.neogul_coder.domain.studypost.Category; +import grep.neogul_coder.domain.studypost.StudyPost; +import grep.neogul_coder.domain.studypost.comment.StudyPostComment; +import grep.neogul_coder.domain.studypost.comment.repository.StudyCommentRepository; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostSaveRequest; +import grep.neogul_coder.domain.studypost.controller.dto.request.StudyPostUpdateRequest; +import grep.neogul_coder.domain.studypost.controller.dto.response.StudyPostDetailResponse; +import grep.neogul_coder.domain.studypost.repository.StudyPostRepository; +import grep.neogul_coder.domain.users.entity.User; +import grep.neogul_coder.domain.users.repository.UserRepository; +import grep.neogul_coder.global.exception.business.NotFoundException; +import jakarta.persistence.EntityManager; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.Collection; +import java.util.List; + +import static grep.neogul_coder.domain.studypost.Category.FREE; +import static grep.neogul_coder.domain.studypost.Category.NOTICE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StudyPostServiceTest extends IntegrationTestSupport { + + @Autowired + private StudyPostService studyPostService; + + @Autowired + private EntityManager em; + + @Autowired + private UserRepository userRepository; + + @Autowired + private StudyRepository studyRepository; + + @Autowired + private StudyMemberRepository studyMemberRepository; + + @Autowired + private StudyPostRepository studyPostRepository; + + @Autowired + private StudyCommentRepository studycommentRepository; + + @DisplayName("게시글을 조회 합니다.") + @Test + void findOne() { + //given + User user = createUser("테스터"); + userRepository.save(user); + + Study study = createStudy("자바 스터디"); + studyRepository.save(study); + + StudyPost post = createStudyPost(user.getId(), "제목", FREE, "내용"); + post.connectStudy(study); + studyPostRepository.save(post); + + List comments = List.of( + createPostComment(post.getId(), user.getId(), "댓글1"), + createPostComment(post.getId(), user.getId(), "댓글2"), + createPostComment(post.getId(), user.getId(), "댓글3") + ); + studycommentRepository.saveAll(comments); + + //when + StudyPostDetailResponse response = studyPostService.findOne(post.getId()); + + //then + assertThat(response.getPostInfo()) + .extracting("nickname", "title") + .containsExactly("테스터", "제목"); + + assertThat(response.getCommentCount()).isEqualTo(3); + + assertThat(response.getComments()) + .extracting("nickname", "content") + .containsExactlyInAnyOrder( + Tuple.tuple("테스터", "댓글1"), + Tuple.tuple("테스터", "댓글2"), + Tuple.tuple("테스터", "댓글3") + ); + } + + @DisplayName("스터디 게시글을 생성 합니다.") + @TestFactory + Collection create() { + User user = createUser("테스터"); + userRepository.save(user); + + Study study = createStudy("자바 스터디"); + studyRepository.save(study); + + return List.of( + DynamicTest.dynamicTest("스터디 게시글은 스터디에 참여한 회원만 작성할 수 있습니다.", () -> { + StudyPostSaveRequest request = createStudyPostSaveRequest(study.getId(), "게시글 제목", FREE, "게시글 내용"); + + //when //then + assertThatThrownBy(() -> studyPostService.create(request, user.getId())) + .isInstanceOf(NotFoundException.class).hasMessage("해당 스터디에 참여 하고 있지 않은 회원 입니다."); + }), + + DynamicTest.dynamicTest("스터디 게시글을 생성 합니다.", () -> { + //given + StudyMember studyMember = createStudyMember(study, user.getId()); + studyMemberRepository.save(studyMember); + + StudyPostSaveRequest request = createStudyPostSaveRequest(study.getId(), "게시글 제목", FREE, "게시글 내용"); + + //when + long postId = studyPostService.create(request, user.getId()); + em.flush(); + em.clear(); + + //then + StudyPost studyPost = studyPostRepository.findById(postId).orElseThrow(); + assertThat(studyPost) + .extracting("title", "content") + .containsExactly("게시글 제목", "게시글 내용"); + }) + ); + } + + @DisplayName("스터디 게시글을 수정 합니다") + @Test + void update() { + //given + User user = createUser("테스터"); + userRepository.save(user); + + Study study = createStudy("자바 스터디"); + studyRepository.save(study); + + StudyPost post = createStudyPost(user.getId(), "제목", FREE, "내용"); + post.connectStudy(study); + studyPostRepository.save(post); + + StudyPostUpdateRequest request = createUpdateRequest("수정된 제목", NOTICE, "수정된 내용"); + + //when + studyPostService.update(request, post.getId(), user.getId()); + em.flush(); + em.clear(); + + //then + StudyPost findPost = studyPostRepository.findById(post.getId()).orElseThrow(); + assertThat(findPost) + .extracting("title", "category", "content") + .containsExactly("수정된 제목", NOTICE, "수정된 내용"); + } + + @DisplayName("게시글을 삭제 합니다.") + @Test + void delete() { + //given + User user = createUser("테스터"); + userRepository.save(user); + + Study study = createStudy("자바 스터디"); + studyRepository.save(study); + + StudyPost post = createStudyPost(user.getId(), "제목", FREE, "내용"); + post.connectStudy(study); + studyPostRepository.save(post); + + //when + studyPostService.delete(post.getId(), user.getId()); + em.flush(); + em.clear(); + + //then + StudyPost findPost = studyPostRepository.findById(post.getId()).orElseThrow(); + assertThat(findPost.getActivated()).isFalse(); + } + + private User createUser(String nickname) { + return User.builder() + .nickname(nickname) + .password("tempPassword") + .build(); + } + + private Study createStudy(String name) { + return Study.builder() + .name(name) + .build(); + } + + private StudyPostSaveRequest createStudyPostSaveRequest(long studyId, String title, Category category, String content) { + return StudyPostSaveRequest.builder() + .studyId(studyId) + .title(title) + .category(category) + .content(content) + .build(); + } + + private StudyMember createStudyMember(Study study, long userId) { + return StudyMember.builder() + .study(study) + .userId(userId) + .build(); + } + + private StudyPost createStudyPost(long userId, String title, Category category, String content) { + return StudyPost.builder() + .userId(userId) + .title(title) + .category(category) + .content(content) + .build(); + } + + private StudyPostUpdateRequest createUpdateRequest(String title, Category category, String content) { + return StudyPostUpdateRequest.builder() + .title(title) + .category(category) + .content(content) + .build(); + } + + private StudyPostComment createPostComment(long postId, long userId, String content){ + return StudyPostComment.builder() + .postId(postId) + .userId(userId) + .content(content) + .build(); + } +} \ No newline at end of file