Skip to content

Commit 0cc5b10

Browse files
committed
✨Feat: 동호회 신청, 실시간 알람 기능 구현
1 parent e05a202 commit 0cc5b10

13 files changed

Lines changed: 668 additions & 1 deletion

src/main/java/com/be/sportizebe/domain/club/controller/ClubController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
@RestController
2323
@RequiredArgsConstructor
2424
@RequestMapping("/api/clubs")
25-
@Tag(name = "club", description = "동호회 관련 API")
25+
@Tag(name = "club", description = "동호회 관리 관련 API")
2626
public class ClubController {
2727

2828
private final ClubServiceImpl clubService;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
package com.be.sportizebe.domain.club.repository;
22

3+
import com.be.sportizebe.domain.club.entity.Club;
34
import com.be.sportizebe.domain.club.entity.ClubMember;
5+
import com.be.sportizebe.domain.user.entity.User;
46
import org.springframework.data.jpa.repository.JpaRepository;
57

68
public interface ClubMemberRepository extends JpaRepository<ClubMember, Long> {
9+
10+
// 특정 사용자가 특정 동호회에 이미 가입했는지 확인
11+
boolean existsByClubAndUser(Club club, User user);
712
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package com.be.sportizebe.domain.notification.controller;
2+
3+
import com.be.sportizebe.domain.notification.dto.response.JoinClubRequestResponse;
4+
import com.be.sportizebe.domain.notification.service.JoinClubRequestServiceImpl;
5+
import com.be.sportizebe.global.cache.dto.UserAuthInfo;
6+
import com.be.sportizebe.global.response.BaseResponse;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.Parameter;
9+
import io.swagger.v3.oas.annotations.tags.Tag;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.http.HttpStatus;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
14+
import org.springframework.web.bind.annotation.*;
15+
16+
import java.util.List;
17+
18+
@RestController
19+
@RequiredArgsConstructor
20+
@RequestMapping("/api/clubs")
21+
@Tag(name = "join-request", description = "동호회 가입 관련 API")
22+
public class JoinClubRequestController {
23+
24+
private final JoinClubRequestServiceImpl joinClubRequestService;
25+
26+
@PostMapping("/{clubId}/join")
27+
@Operation(summary = "가입 신청", description = "동호회에 가입을 신청합니다. 동호회장에게 알림이 전송됩니다.")
28+
public ResponseEntity<BaseResponse<JoinClubRequestResponse>> requestJoin(
29+
@Parameter(description = "동호회 ID") @PathVariable Long clubId,
30+
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
31+
JoinClubRequestResponse response = joinClubRequestService.requestJoin(clubId, userAuthInfo.getId());
32+
return ResponseEntity.status(HttpStatus.CREATED)
33+
.body(BaseResponse.success("가입 신청 완료", response));
34+
}
35+
36+
@DeleteMapping("/join-requests/{requestId}")
37+
@Operation(summary = "가입 신청 취소", description = "본인의 가입 신청을 취소합니다.")
38+
public ResponseEntity<BaseResponse<Void>> cancelRequest(
39+
@Parameter(description = "가입 신청 ID") @PathVariable Long requestId,
40+
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
41+
joinClubRequestService.cancelRequest(requestId, userAuthInfo.getId());
42+
return ResponseEntity.ok(BaseResponse.success("가입 신청 취소 완료", null));
43+
}
44+
45+
@PostMapping("/join-requests/{requestId}/approve")
46+
@Operation(summary = "가입 승인", description = "가입 신청을 승인합니다. 동호회장만 가능합니다.")
47+
public ResponseEntity<BaseResponse<JoinClubRequestResponse>> approveRequest(
48+
@Parameter(description = "가입 신청 ID") @PathVariable Long requestId,
49+
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
50+
JoinClubRequestResponse response = joinClubRequestService.approveRequest(requestId, userAuthInfo.getId());
51+
return ResponseEntity.ok(BaseResponse.success("가입 승인 완료", response));
52+
}
53+
54+
@PostMapping("/join-requests/{requestId}/reject")
55+
@Operation(summary = "가입 거절", description = "가입 신청을 거절합니다. 동호회장만 가능합니다.")
56+
public ResponseEntity<BaseResponse<JoinClubRequestResponse>> rejectRequest(
57+
@Parameter(description = "가입 신청 ID") @PathVariable Long requestId,
58+
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
59+
JoinClubRequestResponse response = joinClubRequestService.rejectRequest(requestId, userAuthInfo.getId());
60+
return ResponseEntity.ok(BaseResponse.success("가입 거절 완료", response));
61+
}
62+
63+
@GetMapping("/{clubId}/join-requests")
64+
@Operation(summary = "대기 중인 가입 신청 목록", description = "동호회의 대기 중인 가입 신청 목록을 조회합니다. 동호회장만 가능합니다.")
65+
public ResponseEntity<BaseResponse<List<JoinClubRequestResponse>>> getPendingRequests(
66+
@Parameter(description = "동호회 ID") @PathVariable Long clubId,
67+
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
68+
List<JoinClubRequestResponse> response = joinClubRequestService.getPendingRequests(clubId, userAuthInfo.getId());
69+
return ResponseEntity.ok(BaseResponse.success("가입 신청 목록 조회 성공", response));
70+
}
71+
72+
@GetMapping("/my-join-requests")
73+
@Operation(summary = "내 가입 신청 목록", description = "내가 신청한 가입 신청 목록을 조회합니다.")
74+
public ResponseEntity<BaseResponse<List<JoinClubRequestResponse>>> getMyRequests(
75+
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
76+
List<JoinClubRequestResponse> response = joinClubRequestService.getMyRequests(userAuthInfo.getId());
77+
return ResponseEntity.ok(BaseResponse.success("내 가입 신청 목록 조회 성공", response));
78+
}
79+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.be.sportizebe.domain.notification.controller;
2+
3+
import com.be.sportizebe.domain.notification.dto.response.NotificationResponse;
4+
import com.be.sportizebe.domain.notification.service.NotificationServiceImpl;
5+
import com.be.sportizebe.domain.user.entity.User;
6+
import com.be.sportizebe.domain.user.exception.UserErrorCode;
7+
import com.be.sportizebe.domain.user.repository.UserRepository;
8+
import com.be.sportizebe.global.cache.dto.UserAuthInfo;
9+
import com.be.sportizebe.global.exception.CustomException;
10+
import com.be.sportizebe.global.response.BaseResponse;
11+
import io.swagger.v3.oas.annotations.Operation;
12+
import io.swagger.v3.oas.annotations.Parameter;
13+
import io.swagger.v3.oas.annotations.tags.Tag;
14+
import lombok.RequiredArgsConstructor;
15+
import org.springframework.http.ResponseEntity;
16+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
17+
import org.springframework.web.bind.annotation.*;
18+
19+
import java.util.List;
20+
21+
@RestController
22+
@RequiredArgsConstructor
23+
@RequestMapping("/api/notifications")
24+
@Tag(name = "notification", description = "알림 API")
25+
public class NotificationController {
26+
27+
private final NotificationServiceImpl notificationService;
28+
private final UserRepository userRepository;
29+
30+
@GetMapping
31+
@Operation(summary = "알림 목록 조회", description = "나의 모든 알림을 조회합니다.")
32+
public ResponseEntity<BaseResponse<List<NotificationResponse>>> getNotifications(
33+
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
34+
User user = findUserById(userAuthInfo.getId());
35+
List<NotificationResponse> response = notificationService.getNotifications(user);
36+
return ResponseEntity.ok(BaseResponse.success("알림 목록 조회 성공", response));
37+
}
38+
39+
@GetMapping("/unread")
40+
@Operation(summary = "읽지 않은 알림 조회", description = "읽지 않은 알림 목록을 조회합니다.")
41+
public ResponseEntity<BaseResponse<List<NotificationResponse>>> getUnreadNotifications(
42+
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
43+
User user = findUserById(userAuthInfo.getId());
44+
List<NotificationResponse> response = notificationService.getUnreadNotifications(user);
45+
return ResponseEntity.ok(BaseResponse.success("읽지 않은 알림 조회 성공", response));
46+
}
47+
48+
@GetMapping("/unread/count")
49+
@Operation(summary = "읽지 않은 알림 개수", description = "읽지 않은 알림 개수를 조회합니다.")
50+
public ResponseEntity<BaseResponse<Long>> getUnreadCount(
51+
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
52+
User user = findUserById(userAuthInfo.getId());
53+
long count = notificationService.getUnreadCount(user);
54+
return ResponseEntity.ok(BaseResponse.success("읽지 않은 알림 개수 조회 성공", count));
55+
}
56+
57+
@PatchMapping("/{notificationId}/read")
58+
@Operation(summary = "알림 읽음 처리", description = "알림을 읽음 처리합니다.")
59+
public ResponseEntity<BaseResponse<Void>> markAsRead(
60+
@Parameter(description = "알림 ID") @PathVariable Long notificationId,
61+
@AuthenticationPrincipal UserAuthInfo userAuthInfo) {
62+
notificationService.markAsRead(notificationId);
63+
return ResponseEntity.ok(BaseResponse.success("알림 읽음 처리 완료", null));
64+
}
65+
66+
private User findUserById(Long userId) {
67+
return userRepository.findById(userId)
68+
.orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND));
69+
}
70+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.be.sportizebe.domain.notification.dto.response;
2+
3+
import com.be.sportizebe.domain.notification.entity.JoinClubRequest;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
import lombok.Builder;
6+
7+
import java.time.LocalDateTime;
8+
9+
@Builder
10+
@Schema(title = "JoinClubRequestResponse DTO", description = "가입 신청 응답")
11+
public record JoinClubRequestResponse(
12+
@Schema(description = "가입 신청 ID") Long id,
13+
@Schema(description = "신청자 ID") Long userId,
14+
@Schema(description = "신청자 닉네임") String userNickname,
15+
@Schema(description = "신청자 프로필 이미지") String userProfileImage,
16+
@Schema(description = "동호회 ID") Long clubId,
17+
@Schema(description = "동호회 이름") String clubName,
18+
@Schema(description = "신청 상태") JoinClubRequest.JoinClubRequestStatus status,
19+
@Schema(description = "신청 일시") LocalDateTime createdAt
20+
) {
21+
public static JoinClubRequestResponse from(JoinClubRequest request) {
22+
return JoinClubRequestResponse.builder()
23+
.id(request.getId())
24+
.userId(request.getUser().getId())
25+
.userNickname(request.getUser().getNickname())
26+
.userProfileImage(request.getUser().getProfileImage())
27+
.clubId(request.getClub().getId())
28+
.clubName(request.getClub().getName())
29+
.status(request.getStatus())
30+
.createdAt(request.getCreatedAt())
31+
.build();
32+
}
33+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.be.sportizebe.domain.notification.dto.response;
2+
3+
import com.be.sportizebe.domain.notification.entity.Notification;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
import lombok.Builder;
6+
7+
import java.time.LocalDateTime;
8+
9+
@Builder
10+
@Schema(title = "NotificationResponse DTO", description = "알림 응답")
11+
public record NotificationResponse(
12+
@Schema(description = "알림 ID") Long id,
13+
@Schema(description = "알림 타입") Notification.NotificationType type,
14+
@Schema(description = "알림 메시지") String message,
15+
@Schema(description = "읽음 여부") Boolean isRead,
16+
@Schema(description = "관련 가입 신청 ID") Long joinRequestId,
17+
@Schema(description = "관련 대상 ID (댓글, 채팅 등)") Long targetId,
18+
@Schema(description = "알림 생성 일시") LocalDateTime createdAt
19+
) {
20+
public static NotificationResponse from(Notification notification) {
21+
return NotificationResponse.builder()
22+
.id(notification.getId())
23+
.type(notification.getType())
24+
.message(notification.getMessage())
25+
.isRead(notification.getIsRead())
26+
.joinRequestId(notification.getJoinClubRequest() != null
27+
? notification.getJoinClubRequest().getId() : null)
28+
.targetId(notification.getTargetId())
29+
.createdAt(notification.getCreatedAt())
30+
.build();
31+
}
32+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.be.sportizebe.domain.notification.exception;
2+
3+
import com.be.sportizebe.global.exception.model.BaseErrorCode;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Getter;
6+
import org.springframework.http.HttpStatus;
7+
8+
@Getter
9+
@AllArgsConstructor
10+
public enum JoinClubRequestErrorCode implements BaseErrorCode {
11+
JOIN_REQUEST_NOT_FOUND("JOIN_001", "가입 신청을 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
12+
JOIN_REQUEST_ALREADY_EXISTS("JOIN_002", "이미 가입 신청한 동호회입니다.", HttpStatus.CONFLICT),
13+
ALREADY_CLUB_MEMBER("JOIN_003", "이미 가입된 동호회입니다.", HttpStatus.CONFLICT),
14+
JOIN_REQUEST_NOT_PENDING("JOIN_004", "대기 중인 가입 신청이 아닙니다.", HttpStatus.BAD_REQUEST),
15+
CANNOT_JOIN_OWN_CLUB("JOIN_005", "자신이 만든 동호회에는 가입 신청할 수 없습니다.", HttpStatus.BAD_REQUEST),
16+
CLUB_FULL("JOIN_006", "동호회 정원이 가득 찼습니다.", HttpStatus.BAD_REQUEST);
17+
18+
private final String code;
19+
private final String message;
20+
private final HttpStatus status;
21+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.be.sportizebe.domain.notification.repository;
2+
3+
import com.be.sportizebe.domain.club.entity.Club;
4+
import com.be.sportizebe.domain.notification.entity.JoinClubRequest;
5+
import com.be.sportizebe.domain.user.entity.User;
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
8+
import java.util.List;
9+
import java.util.Optional;
10+
11+
public interface JoinClubRequestRepository extends JpaRepository<JoinClubRequest, Long> {
12+
13+
// 특정 사용자의 특정 동호회 가입 신청 조회
14+
Optional<JoinClubRequest> findByUserAndClub(User user, Club club);
15+
16+
// 특정 사용자가 특정 동호회에 이미 신청했는지 확인
17+
boolean existsByUserAndClub(User user, Club club);
18+
19+
// 특정 동호회의 대기 중인 가입 신청 목록
20+
List<JoinClubRequest> findByClubAndStatus(Club club, JoinClubRequest.JoinClubRequestStatus status);
21+
22+
// 특정 사용자의 가입 신청 목록
23+
List<JoinClubRequest> findByUser(User user);
24+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.be.sportizebe.domain.notification.repository;
2+
3+
import com.be.sportizebe.domain.notification.entity.Notification;
4+
import com.be.sportizebe.domain.user.entity.User;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
7+
import java.util.List;
8+
9+
public interface NotificationRepository extends JpaRepository<Notification, Long> {
10+
11+
// 특정 사용자의 알림 목록 (최신순)
12+
List<Notification> findByReceiverOrderByCreatedAtDesc(User receiver);
13+
14+
// 특정 사용자의 읽지 않은 알림 목록
15+
List<Notification> findByReceiverAndIsReadFalseOrderByCreatedAtDesc(User receiver);
16+
17+
// 특정 사용자의 읽지 않은 알림 개수
18+
long countByReceiverAndIsReadFalse(User receiver);
19+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.be.sportizebe.domain.notification.service;
2+
3+
import com.be.sportizebe.domain.notification.dto.response.JoinClubRequestResponse;
4+
5+
import java.util.List;
6+
7+
public interface JoinClubRequestService {
8+
9+
// 가입 신청
10+
JoinClubRequestResponse requestJoin(Long clubId, Long userId);
11+
12+
// 가입 신청 취소
13+
void cancelRequest(Long requestId, Long userId);
14+
15+
// 가입 승인 (동호회장만)
16+
JoinClubRequestResponse approveRequest(Long requestId, Long leaderId);
17+
18+
// 가입 거절 (동호회장만)
19+
JoinClubRequestResponse rejectRequest(Long requestId, Long leaderId);
20+
21+
// 동호회의 대기 중인 가입 신청 목록 조회 (동호회장만)
22+
List<JoinClubRequestResponse> getPendingRequests(Long clubId, Long leaderId);
23+
24+
// 내 가입 신청 목록 조회
25+
List<JoinClubRequestResponse> getMyRequests(Long userId);
26+
}

0 commit comments

Comments
 (0)