diff --git a/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java b/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java index 1bdf12e..9d80b84 100644 --- a/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java +++ b/src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java @@ -22,7 +22,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/clubs") -@Tag(name = "club", description = "동호회 관련 API") +@Tag(name = "club", description = "동호회 관리 관련 API") public class ClubController { private final ClubServiceImpl clubService; diff --git a/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java b/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java index c904683..f98e969 100644 --- a/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/com/be/sportizebe/domain/club/repository/ClubMemberRepository.java @@ -1,7 +1,12 @@ package com.be.sportizebe.domain.club.repository; +import com.be.sportizebe.domain.club.entity.Club; import com.be.sportizebe.domain.club.entity.ClubMember; +import com.be.sportizebe.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; public interface ClubMemberRepository extends JpaRepository { + + // 특정 사용자가 특정 동호회에 이미 가입했는지 확인 + boolean existsByClubAndUser(Club club, User user); } diff --git a/src/main/java/com/be/sportizebe/domain/notification/controller/JoinClubRequestController.java b/src/main/java/com/be/sportizebe/domain/notification/controller/JoinClubRequestController.java new file mode 100644 index 0000000..4dab40c --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/notification/controller/JoinClubRequestController.java @@ -0,0 +1,79 @@ +package com.be.sportizebe.domain.notification.controller; + +import com.be.sportizebe.domain.notification.dto.response.JoinClubRequestResponse; +import com.be.sportizebe.domain.notification.service.JoinClubRequestService; +import com.be.sportizebe.global.cache.dto.UserAuthInfo; +import com.be.sportizebe.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/clubs") +@Tag(name = "join-request", description = "동호회 가입 관련 API") +public class JoinClubRequestController { + + private final JoinClubRequestService joinClubRequestService; + + @PostMapping("/{clubId}/join") + @Operation(summary = "가입 신청", description = "동호회에 가입을 신청합니다. 동호회장에게 알림이 전송됩니다.") + public ResponseEntity> requestJoin( + @Parameter(description = "동호회 ID") @PathVariable Long clubId, + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + JoinClubRequestResponse response = joinClubRequestService.requestJoin(clubId, userAuthInfo.getId()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(BaseResponse.success("가입 신청 완료", response)); + } + + @DeleteMapping("/join-requests/{requestId}") + @Operation(summary = "가입 신청 취소", description = "본인의 가입 신청을 취소합니다.") + public ResponseEntity> cancelRequest( + @Parameter(description = "가입 신청 ID") @PathVariable Long requestId, + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + joinClubRequestService.cancelRequest(requestId, userAuthInfo.getId()); + return ResponseEntity.ok(BaseResponse.success("가입 신청 취소 완료", null)); + } + + @PostMapping("/join-requests/{requestId}/approve") + @Operation(summary = "가입 승인", description = "가입 신청을 승인합니다. 동호회장만 가능합니다.") + public ResponseEntity> approveRequest( + @Parameter(description = "가입 신청 ID") @PathVariable Long requestId, + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + JoinClubRequestResponse response = joinClubRequestService.approveRequest(requestId, userAuthInfo.getId()); + return ResponseEntity.ok(BaseResponse.success("가입 승인 완료", response)); + } + + @PostMapping("/join-requests/{requestId}/reject") + @Operation(summary = "가입 거절", description = "가입 신청을 거절합니다. 동호회장만 가능합니다.") + public ResponseEntity> rejectRequest( + @Parameter(description = "가입 신청 ID") @PathVariable Long requestId, + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + JoinClubRequestResponse response = joinClubRequestService.rejectRequest(requestId, userAuthInfo.getId()); + return ResponseEntity.ok(BaseResponse.success("가입 거절 완료", response)); + } + + @GetMapping("/{clubId}/join-requests") + @Operation(summary = "대기 중인 가입 신청 목록", description = "동호회의 대기 중인 가입 신청 목록을 조회합니다. 동호회장만 가능합니다.") + public ResponseEntity>> getPendingRequests( + @Parameter(description = "동호회 ID") @PathVariable Long clubId, + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + List response = joinClubRequestService.getPendingRequests(clubId, userAuthInfo.getId()); + return ResponseEntity.ok(BaseResponse.success("가입 신청 목록 조회 성공", response)); + } + + @GetMapping("/my-join-requests") + @Operation(summary = "내 가입 신청 목록", description = "내가 신청한 가입 신청 목록을 조회합니다.") + public ResponseEntity>> getMyRequests( + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + List response = joinClubRequestService.getMyRequests(userAuthInfo.getId()); + return ResponseEntity.ok(BaseResponse.success("내 가입 신청 목록 조회 성공", response)); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/notification/controller/NotificationController.java b/src/main/java/com/be/sportizebe/domain/notification/controller/NotificationController.java new file mode 100644 index 0000000..873d2a1 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/notification/controller/NotificationController.java @@ -0,0 +1,70 @@ +package com.be.sportizebe.domain.notification.controller; + +import com.be.sportizebe.domain.notification.dto.response.NotificationResponse; +import com.be.sportizebe.domain.notification.service.NotificationServiceImpl; +import com.be.sportizebe.domain.user.entity.User; +import com.be.sportizebe.domain.user.exception.UserErrorCode; +import com.be.sportizebe.domain.user.repository.UserRepository; +import com.be.sportizebe.global.cache.dto.UserAuthInfo; +import com.be.sportizebe.global.exception.CustomException; +import com.be.sportizebe.global.response.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +@Tag(name = "notification", description = "알림 API") +public class NotificationController { + + private final NotificationServiceImpl notificationService; + private final UserRepository userRepository; + + @GetMapping + @Operation(summary = "알림 목록 조회", description = "나의 모든 알림을 조회합니다.") + public ResponseEntity>> getNotifications( + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + User user = findUserById(userAuthInfo.getId()); + List response = notificationService.getNotifications(user); + return ResponseEntity.ok(BaseResponse.success("알림 목록 조회 성공", response)); + } + + @GetMapping("/unread") + @Operation(summary = "읽지 않은 알림 조회", description = "읽지 않은 알림 목록을 조회합니다.") + public ResponseEntity>> getUnreadNotifications( + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + User user = findUserById(userAuthInfo.getId()); + List response = notificationService.getUnreadNotifications(user); + return ResponseEntity.ok(BaseResponse.success("읽지 않은 알림 조회 성공", response)); + } + + @GetMapping("/unread/count") + @Operation(summary = "읽지 않은 알림 개수", description = "읽지 않은 알림 개수를 조회합니다.") + public ResponseEntity> getUnreadCount( + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + User user = findUserById(userAuthInfo.getId()); + long count = notificationService.getUnreadCount(user); + return ResponseEntity.ok(BaseResponse.success("읽지 않은 알림 개수 조회 성공", count)); + } + + @PatchMapping("/{notificationId}/read") + @Operation(summary = "알림 읽음 처리", description = "알림을 읽음 처리합니다.") + public ResponseEntity> markAsRead( + @Parameter(description = "알림 ID") @PathVariable Long notificationId, + @AuthenticationPrincipal UserAuthInfo userAuthInfo) { + notificationService.markAsRead(notificationId, userAuthInfo.getId()); + return ResponseEntity.ok(BaseResponse.success("알림 읽음 처리 완료", null)); + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/notification/dto/response/JoinClubRequestResponse.java b/src/main/java/com/be/sportizebe/domain/notification/dto/response/JoinClubRequestResponse.java new file mode 100644 index 0000000..1a1517c --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/notification/dto/response/JoinClubRequestResponse.java @@ -0,0 +1,33 @@ +package com.be.sportizebe.domain.notification.dto.response; + +import com.be.sportizebe.domain.notification.entity.JoinClubRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +@Schema(title = "JoinClubRequestResponse DTO", description = "가입 신청 응답") +public record JoinClubRequestResponse( + @Schema(description = "가입 신청 ID") Long id, + @Schema(description = "신청자 ID") Long userId, + @Schema(description = "신청자 닉네임") String userNickname, + @Schema(description = "신청자 프로필 이미지") String userProfileImage, + @Schema(description = "동호회 ID") Long clubId, + @Schema(description = "동호회 이름") String clubName, + @Schema(description = "신청 상태") JoinClubRequest.JoinClubRequestStatus status, + @Schema(description = "신청 일시") LocalDateTime createdAt +) { + public static JoinClubRequestResponse from(JoinClubRequest request) { + return JoinClubRequestResponse.builder() + .id(request.getId()) + .userId(request.getUser().getId()) + .userNickname(request.getUser().getNickname()) + .userProfileImage(request.getUser().getProfileImage()) + .clubId(request.getClub().getId()) + .clubName(request.getClub().getName()) + .status(request.getStatus()) + .createdAt(request.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/notification/dto/response/NotificationResponse.java b/src/main/java/com/be/sportizebe/domain/notification/dto/response/NotificationResponse.java new file mode 100644 index 0000000..33fc66a --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/notification/dto/response/NotificationResponse.java @@ -0,0 +1,32 @@ +package com.be.sportizebe.domain.notification.dto.response; + +import com.be.sportizebe.domain.notification.entity.Notification; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +@Schema(title = "NotificationResponse DTO", description = "알림 응답") +public record NotificationResponse( + @Schema(description = "알림 ID") Long id, + @Schema(description = "알림 타입") Notification.NotificationType type, + @Schema(description = "알림 메시지") String message, + @Schema(description = "읽음 여부") Boolean isRead, + @Schema(description = "관련 가입 신청 ID") Long joinRequestId, + @Schema(description = "관련 대상 ID (댓글, 채팅 등)") Long targetId, + @Schema(description = "알림 생성 일시") LocalDateTime createdAt +) { + public static NotificationResponse from(Notification notification) { + return NotificationResponse.builder() + .id(notification.getId()) + .type(notification.getType()) + .message(notification.getMessage()) + .isRead(notification.getIsRead()) + .joinRequestId(notification.getJoinClubRequest() != null + ? notification.getJoinClubRequest().getId() : null) + .targetId(notification.getTargetId()) + .createdAt(notification.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/notification/exception/JoinClubRequestErrorCode.java b/src/main/java/com/be/sportizebe/domain/notification/exception/JoinClubRequestErrorCode.java new file mode 100644 index 0000000..05011fd --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/notification/exception/JoinClubRequestErrorCode.java @@ -0,0 +1,21 @@ +package com.be.sportizebe.domain.notification.exception; + +import com.be.sportizebe.global.exception.model.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum JoinClubRequestErrorCode implements BaseErrorCode { + JOIN_REQUEST_NOT_FOUND("JOIN_001", "가입 신청을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + JOIN_REQUEST_ALREADY_EXISTS("JOIN_002", "이미 가입 신청한 동호회입니다.", HttpStatus.CONFLICT), + ALREADY_CLUB_MEMBER("JOIN_003", "이미 가입된 동호회입니다.", HttpStatus.CONFLICT), + JOIN_REQUEST_NOT_PENDING("JOIN_004", "대기 중인 가입 신청이 아닙니다.", HttpStatus.BAD_REQUEST), + CANNOT_JOIN_OWN_CLUB("JOIN_005", "자신이 만든 동호회에는 가입 신청할 수 없습니다.", HttpStatus.BAD_REQUEST), + CLUB_FULL("JOIN_006", "동호회 정원이 가득 찼습니다.", HttpStatus.BAD_REQUEST); + + private final String code; + private final String message; + private final HttpStatus status; +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/notification/repository/JoinClubRequestRepository.java b/src/main/java/com/be/sportizebe/domain/notification/repository/JoinClubRequestRepository.java new file mode 100644 index 0000000..f0f94ed --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/notification/repository/JoinClubRequestRepository.java @@ -0,0 +1,24 @@ +package com.be.sportizebe.domain.notification.repository; + +import com.be.sportizebe.domain.club.entity.Club; +import com.be.sportizebe.domain.notification.entity.JoinClubRequest; +import com.be.sportizebe.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface JoinClubRequestRepository extends JpaRepository { + + // 특정 사용자의 특정 동호회 가입 신청 조회 + Optional findByUserAndClub(User user, Club club); + + // 특정 사용자가 특정 동호회에 이미 신청했는지 확인 + boolean existsByUserAndClub(User user, Club club); + + // 특정 동호회의 대기 중인 가입 신청 목록 + List findByClubAndStatus(Club club, JoinClubRequest.JoinClubRequestStatus status); + + // 특정 사용자의 가입 신청 목록 + List findByUser(User user); +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/notification/repository/NotificationRepository.java b/src/main/java/com/be/sportizebe/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..3b73628 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,19 @@ +package com.be.sportizebe.domain.notification.repository; + +import com.be.sportizebe.domain.notification.entity.Notification; +import com.be.sportizebe.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface NotificationRepository extends JpaRepository { + + // 특정 사용자의 알림 목록 (최신순) + List findByReceiverOrderByCreatedAtDesc(User receiver); + + // 특정 사용자의 읽지 않은 알림 목록 + List findByReceiverAndIsReadFalseOrderByCreatedAtDesc(User receiver); + + // 특정 사용자의 읽지 않은 알림 개수 + long countByReceiverAndIsReadFalse(User receiver); +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/notification/service/JoinClubRequestService.java b/src/main/java/com/be/sportizebe/domain/notification/service/JoinClubRequestService.java new file mode 100644 index 0000000..fbe9ddd --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/notification/service/JoinClubRequestService.java @@ -0,0 +1,26 @@ +package com.be.sportizebe.domain.notification.service; + +import com.be.sportizebe.domain.notification.dto.response.JoinClubRequestResponse; + +import java.util.List; + +public interface JoinClubRequestService { + + // 가입 신청 + JoinClubRequestResponse requestJoin(Long clubId, Long userId); + + // 가입 신청 취소 + void cancelRequest(Long requestId, Long userId); + + // 가입 승인 (동호회장만) + JoinClubRequestResponse approveRequest(Long requestId, Long leaderId); + + // 가입 거절 (동호회장만) + JoinClubRequestResponse rejectRequest(Long requestId, Long leaderId); + + // 동호회의 대기 중인 가입 신청 목록 조회 (동호회장만) + List getPendingRequests(Long clubId, Long leaderId); + + // 내 가입 신청 목록 조회 + List getMyRequests(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/notification/service/JoinClubRequestServiceImpl.java b/src/main/java/com/be/sportizebe/domain/notification/service/JoinClubRequestServiceImpl.java new file mode 100644 index 0000000..6729ad9 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/notification/service/JoinClubRequestServiceImpl.java @@ -0,0 +1,204 @@ +package com.be.sportizebe.domain.notification.service; + +import com.be.sportizebe.domain.club.entity.Club; +import com.be.sportizebe.domain.club.entity.ClubMember; +import com.be.sportizebe.domain.club.exception.ClubErrorCode; +import com.be.sportizebe.domain.club.repository.ClubMemberRepository; +import com.be.sportizebe.domain.club.repository.ClubRepository; +import com.be.sportizebe.domain.notification.dto.response.JoinClubRequestResponse; +import com.be.sportizebe.domain.notification.entity.JoinClubRequest; +import com.be.sportizebe.domain.notification.exception.JoinClubRequestErrorCode; +import com.be.sportizebe.domain.notification.repository.JoinClubRequestRepository; +import com.be.sportizebe.domain.user.entity.User; +import com.be.sportizebe.domain.user.exception.UserErrorCode; +import com.be.sportizebe.domain.user.repository.UserRepository; +import com.be.sportizebe.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class JoinClubRequestServiceImpl implements JoinClubRequestService { + + private final JoinClubRequestRepository joinClubRequestRepository; + private final ClubRepository clubRepository; + private final ClubMemberRepository clubMemberRepository; + private final UserRepository userRepository; + private final NotificationServiceImpl notificationService; + + @Override + @Transactional + public JoinClubRequestResponse requestJoin(Long clubId, Long userId) { + User user = findUserById(userId); + Club club = findClubById(clubId); + + // 자신이 동호회장인 동호회에는 가입 신청 불가 + if (club.isLeader(userId)) { + throw new CustomException(JoinClubRequestErrorCode.CANNOT_JOIN_OWN_CLUB); + } + + // 이미 가입된 회원인지 확인 + if (clubMemberRepository.existsByClubAndUser(club, user)) { + throw new CustomException(JoinClubRequestErrorCode.ALREADY_CLUB_MEMBER); + } + + // 이미 가입 신청했는지 확인 + if (joinClubRequestRepository.existsByUserAndClub(user, club)) { + throw new CustomException(JoinClubRequestErrorCode.JOIN_REQUEST_ALREADY_EXISTS); + } + + // 정원 확인 + if (club.getMembers().size() >= club.getMaxMembers()) { + throw new CustomException(JoinClubRequestErrorCode.CLUB_FULL); + } + + // 가입 신청 생성 (상태는 PENDING) + JoinClubRequest joinRequest = JoinClubRequest.builder() + .user(user) + .club(club) + .build(); + joinClubRequestRepository.save(joinRequest); + + // 동호회장에게 알림 전송 + User leader = club.getLeader(); + if (leader != null) { + notificationService.createJoinRequestNotification(joinRequest, leader); + } + + log.info("가입 신청 완료: userId={}, clubId={}", userId, clubId); + return JoinClubRequestResponse.from(joinRequest); + } + + @Override + @Transactional + public void cancelRequest(Long requestId, Long userId) { + JoinClubRequest request = findRequestById(requestId); + + // 본인 신청만 취소 가능 + if (request.getUser().getId() != userId) { + throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED); + } + + // 대기 중인 신청만 취소 가능 + if (request.getStatus() != JoinClubRequest.JoinClubRequestStatus.PENDING) { + throw new CustomException(JoinClubRequestErrorCode.JOIN_REQUEST_NOT_PENDING); + } + + joinClubRequestRepository.delete(request); // DELETE FROM join_club_request WHERE id = {request.getId()} 쿼리 사용됨 + log.info("가입 신청 취소: requestId={}, userId={}", requestId, userId); + } + + @Override + @Transactional + public JoinClubRequestResponse approveRequest(Long requestId, Long leaderId) { + JoinClubRequest request = findRequestById(requestId); + Club club = request.getClub(); + + // 동호회장만 승인 가능 + if (!club.isLeader(leaderId)) { + throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED); + } + + // 대기 중인 신청만 승인 가능 + if (request.getStatus() != JoinClubRequest.JoinClubRequestStatus.PENDING) { + throw new CustomException(JoinClubRequestErrorCode.JOIN_REQUEST_NOT_PENDING); + } + + // 정원 확인 + if (club.getMembers().size() >= club.getMaxMembers()) { + throw new CustomException(JoinClubRequestErrorCode.CLUB_FULL); + } + + // 신청 승인 + request.accept(); // 신청 상태 변경 + + // 동호회 멤버로 추가 + ClubMember newMember = ClubMember.builder() + .club(club) + .user(request.getUser()) + .role(ClubMember.ClubRole.MEMBER) + .build(); + clubMemberRepository.save(newMember); + + // 신청자에게 승인 알림 전송 + notificationService.createJoinApprovedNotification(request); + + log.info("가입 승인 완료: requestId={}, userId={}", requestId, request.getUser().getId()); + return JoinClubRequestResponse.from(request); + } + + @Override + @Transactional + public JoinClubRequestResponse rejectRequest(Long requestId, Long leaderId) { + JoinClubRequest request = findRequestById(requestId); + Club club = request.getClub(); + + // 동호회장만 거절 가능 + if (!club.isLeader(leaderId)) { + throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED); + } + + // 대기 중인 신청만 거절 가능 + if (request.getStatus() != JoinClubRequest.JoinClubRequestStatus.PENDING) { + throw new CustomException(JoinClubRequestErrorCode.JOIN_REQUEST_NOT_PENDING); + } + + // 신청 거절 + request.reject(); // 신청 상태 변경 + + // 신청자에게 거절 알림 전송 + notificationService.createJoinRejectedNotification(request); + + log.info("가입 거절 완료: requestId={}, userId={}", requestId, request.getUser().getId()); + return JoinClubRequestResponse.from(request); + } + + @Override + public List getPendingRequests(Long clubId, Long leaderId) { + Club club = findClubById(clubId); + + // 동호회장만 조회 가능 + if (!club.isLeader(leaderId)) { + throw new CustomException(ClubErrorCode.CLUB_UPDATE_DENIED); + } + + return joinClubRequestRepository + .findByClubAndStatus(club, JoinClubRequest.JoinClubRequestStatus.PENDING) + .stream() + .map(JoinClubRequestResponse::from) + .toList(); + } + + @Override + public List getMyRequests(Long userId) { + User user = findUserById(userId); + return joinClubRequestRepository.findByUser(user) + .stream() + .map(JoinClubRequestResponse::from) + .toList(); + } + + /* + 중복 코드 제거, 일관된 예외처리, 가독성, 유지보수의 목적으로 헬퍼 메서드 사용 + */ + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + } + + private Club findClubById(Long clubId) { + return clubRepository.findById(clubId) + .orElseThrow(() -> new CustomException(ClubErrorCode.CLUB_NOT_FOUND)); + } + + private JoinClubRequest findRequestById(Long requestId) { + return joinClubRequestRepository.findById(requestId) + .orElseThrow(() -> new CustomException(JoinClubRequestErrorCode.JOIN_REQUEST_NOT_FOUND)); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/notification/service/NotificationService.java b/src/main/java/com/be/sportizebe/domain/notification/service/NotificationService.java new file mode 100644 index 0000000..0494ae8 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/notification/service/NotificationService.java @@ -0,0 +1,32 @@ +package com.be.sportizebe.domain.notification.service; + +import com.be.sportizebe.domain.notification.dto.response.NotificationResponse; +import com.be.sportizebe.domain.notification.entity.JoinClubRequest; +import com.be.sportizebe.domain.notification.entity.Notification; +import com.be.sportizebe.domain.user.entity.User; + +import java.util.List; + +public interface NotificationService { + + // 가입 신청 알림 생성 및 웹소켓 전송 + Notification createJoinRequestNotification(JoinClubRequest joinRequest, User receiver); + + // 가입 승인 알림 생성 및 웹소켓 전송 + Notification createJoinApprovedNotification(JoinClubRequest joinRequest); + + // 가입 거절 알림 생성 및 웹소켓 전송 + Notification createJoinRejectedNotification(JoinClubRequest joinRequest); + + // 사용자의 모든 알림 조회 + List getNotifications(User user); + + // 읽지 않은 알림 조회 + List getUnreadNotifications(User user); + + // 읽지 않은 알림 개수 + long getUnreadCount(User user); + + // 알림 읽음 처리 + void markAsRead(Long notificationId, Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/notification/service/NotificationServiceImpl.java b/src/main/java/com/be/sportizebe/domain/notification/service/NotificationServiceImpl.java new file mode 100644 index 0000000..24ff39a --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/notification/service/NotificationServiceImpl.java @@ -0,0 +1,128 @@ +package com.be.sportizebe.domain.notification.service; + +import com.be.sportizebe.domain.notification.dto.response.NotificationResponse; +import com.be.sportizebe.domain.notification.entity.JoinClubRequest; +import com.be.sportizebe.domain.notification.entity.Notification; +import com.be.sportizebe.domain.notification.repository.NotificationRepository; +import com.be.sportizebe.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationServiceImpl implements NotificationService { + + private final NotificationRepository notificationRepository; + private final SimpMessagingTemplate messagingTemplate; + + @Override + @Transactional + public Notification createJoinRequestNotification(JoinClubRequest joinRequest, User receiver) { + String message = String.format("%s님이 %s 동호회에 가입을 신청했습니다.", + joinRequest.getUser().getNickname(), + joinRequest.getClub().getName()); + + Notification notification = Notification.builder() + .receiver(receiver) + .type(Notification.NotificationType.JOIN_REQUEST) + .message(message) + .joinClubRequest(joinRequest) + .build(); + + notificationRepository.save(notification); + + // 웹소켓으로 실시간 알림 전송 + sendNotificationToUser(receiver.getId(), notification); + + return notification; + } + + @Override + @Transactional + public Notification createJoinApprovedNotification(JoinClubRequest joinRequest) { + String message = String.format("%s 동호회 가입이 승인되었습니다.", + joinRequest.getClub().getName()); + + Notification notification = Notification.builder() + .receiver(joinRequest.getUser()) + .type(Notification.NotificationType.JOIN_APPROVED) + .message(message) + .joinClubRequest(joinRequest) + .build(); + + notificationRepository.save(notification); + sendNotificationToUser(joinRequest.getUser().getId(), notification); + + return notification; + } + + @Override + @Transactional + public Notification createJoinRejectedNotification(JoinClubRequest joinRequest) { + String message = String.format("%s 동호회 가입이 거절되었습니다.", + joinRequest.getClub().getName()); + + Notification notification = Notification.builder() + .receiver(joinRequest.getUser()) + .type(Notification.NotificationType.JOIN_REJECTED) + .message(message) + .joinClubRequest(joinRequest) + .build(); + + notificationRepository.save(notification); + sendNotificationToUser(joinRequest.getUser().getId(), notification); + + return notification; + } + + @Override + public List getNotifications(User user) { + return notificationRepository.findByReceiverOrderByCreatedAtDesc(user) + .stream() + .map(NotificationResponse::from) + .toList(); + } + + @Override + public List getUnreadNotifications(User user) { + return notificationRepository.findByReceiverAndIsReadFalseOrderByCreatedAtDesc(user) + .stream() + .map(NotificationResponse::from) + .toList(); + } + + @Override + public long getUnreadCount(User user) { + return notificationRepository.countByReceiverAndIsReadFalse(user); + } + + @Override + @Transactional + public void markAsRead(Long notificationId, Long userId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new IllegalArgumentException("알림을 찾을 수 없습니다.")); + + if (!userId.equals(notification.getReceiver().getId())) { + throw new IllegalArgumentException("본인의 알림만 읽음 처리할 수 있습니다."); + } + + notification.markAsRead(); + } + + /** + * 웹소켓으로 알림 전송 + */ + private void sendNotificationToUser(Long userId, Notification notification) { + String destination = "/sub/notifications/" + userId; + NotificationResponse response = NotificationResponse.from(notification); + messagingTemplate.convertAndSend(destination, response); + log.info("알림 전송 완료: userId={}, type={}", userId, notification.getType()); + } +} \ No newline at end of file