diff --git a/src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java b/src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java index 2575622e..3d2e47e5 100644 --- a/src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java +++ b/src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java @@ -10,9 +10,11 @@ public boolean hasAccess(User user, StudentCouncil writer) { user.getSchool().getSchoolId().equals(writer.getSchool().getSchoolId()); } - @Override public String topic(StudentCouncil writer) { + @Override + public String topic(StudentCouncil writer) { return "school_" + writer.getSchool().getSchoolId(); } + }, COLLEGE_COUNCIL { @Override @@ -20,7 +22,9 @@ public boolean hasAccess(User user, StudentCouncil writer) { return user.getCollege() != null && user.getCollege().getCollegeId().equals(writer.getCollege().getCollegeId()); } - @Override public String topic(StudentCouncil writer) { + + @Override + public String topic(StudentCouncil writer) { return "college_" + writer.getCollege().getCollegeId(); } }, @@ -30,11 +34,16 @@ public boolean hasAccess(User user, StudentCouncil writer) { return user.getMajor() != null && user.getMajor().getMajorId().equals(writer.getMajor().getMajorId()); } - @Override public String topic(StudentCouncil writer) { + + @Override + public String topic(StudentCouncil writer) { return "major_" + writer.getMajor().getMajorId(); } + }; public abstract boolean hasAccess(User user, StudentCouncil writer); + public abstract String topic(StudentCouncil writer); + } diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java b/src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java index e9143360..b039ad0c 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java @@ -9,6 +9,7 @@ import com.campus.campus.domain.councilpost.application.dto.request.CouncilPostCreatedEvent; import com.campus.campus.domain.councilpost.domain.entity.PostCategory; +import com.campus.campus.domain.notification.application.service.NotificationService; import com.campus.campus.global.firebase.application.service.FirebaseCloudMessageService; import lombok.RequiredArgsConstructor; @@ -25,6 +26,7 @@ public class CouncilPostPushListener { private static final String DATA_KEY_CATEGORY = "category"; private final FirebaseCloudMessageService firebaseCloudMessageService; + private final NotificationService notificationService; @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @@ -36,6 +38,8 @@ public void handleCouncilPostCreatedEvent(CouncilPostCreatedEvent event) { log.info("[PUSH] after_commit event received. topic={}, postId={}, category={}", event.topic(), event.postId(), event.category()); + notificationService.savePostCreatedNotification(event, title, body); + firebaseCloudMessageService.sendToTopic( event.topic(), title, diff --git a/src/main/java/com/campus/campus/domain/notification/application/dto/CursorResponse.java b/src/main/java/com/campus/campus/domain/notification/application/dto/CursorResponse.java new file mode 100644 index 00000000..ad94b32b --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/application/dto/CursorResponse.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.notification.application.dto; + +import java.util.List; + +public record CursorResponse( + List items, + NextCursor nextCursor, + boolean hasNext +) {} diff --git a/src/main/java/com/campus/campus/domain/notification/application/dto/NextCursor.java b/src/main/java/com/campus/campus/domain/notification/application/dto/NextCursor.java new file mode 100644 index 00000000..85234ffa --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/application/dto/NextCursor.java @@ -0,0 +1,8 @@ +package com.campus.campus.domain.notification.application.dto; + +import java.time.LocalDateTime; + +public record NextCursor( + LocalDateTime createdAt, + Long id +) {} diff --git a/src/main/java/com/campus/campus/domain/notification/application/dto/NotificationResponse.java b/src/main/java/com/campus/campus/domain/notification/application/dto/NotificationResponse.java new file mode 100644 index 00000000..16fbd9ef --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/application/dto/NotificationResponse.java @@ -0,0 +1,29 @@ +package com.campus.campus.domain.notification.application.dto; + +import com.campus.campus.domain.notification.domain.entity.NotificationType; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record NotificationResponse( + @Schema(description = "알림 ID", example = "1") + Long id, + + @Schema(description = "알림 타입", example = "COUNCIL_POST_CREATED") + NotificationType type, + + @Schema(description = "알림 제목", example = "총학생회") + String title, + + @Schema(description = "알림 내용", example = "새 행사글이 등록되었습니다.") + String body, + + @Schema(description = "참조 ID (게시글 ID 등)", example = "123") + Long referenceId, + + @Schema(description = "읽음 여부", example = "false") + boolean isRead, + + @Schema(description = "생성 시각 (상대 시간)", example = "5분 전") + String createTimeBeforeNow +) { +} diff --git a/src/main/java/com/campus/campus/domain/notification/application/exception/ErrorCode.java b/src/main/java/com/campus/campus/domain/notification/application/exception/ErrorCode.java new file mode 100644 index 00000000..5878bb2e --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/application/exception/ErrorCode.java @@ -0,0 +1,21 @@ +package com.campus.campus.domain.notification.application.exception; + +import org.springframework.http.HttpStatus; + +import com.campus.campus.global.common.exception.ErrorCodeInterface; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorCode implements ErrorCodeInterface { + + NOTIFICATION_NOT_FOUND(2801, HttpStatus.NOT_FOUND, "알림을 찾을 수 없습니다."), + NOTIFICATION_ACCESS_DENIED(2802, HttpStatus.FORBIDDEN, "해당 알림에 접근할 권한이 없습니다."), + INVALID_NOTIFICATION_IDS(2803, HttpStatus.BAD_REQUEST, "유효하지 않은 알림 ID 목록입니다."); + + private final int code; + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/campus/campus/domain/notification/application/exception/NotificationAccessDeniedException.java b/src/main/java/com/campus/campus/domain/notification/application/exception/NotificationAccessDeniedException.java new file mode 100644 index 00000000..757accb6 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/application/exception/NotificationAccessDeniedException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.notification.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class NotificationAccessDeniedException extends ApplicationException { + public NotificationAccessDeniedException() { + super(ErrorCode.NOTIFICATION_ACCESS_DENIED); + } +} diff --git a/src/main/java/com/campus/campus/domain/notification/application/exception/NotificationNotFoundException.java b/src/main/java/com/campus/campus/domain/notification/application/exception/NotificationNotFoundException.java new file mode 100644 index 00000000..c21511b8 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/application/exception/NotificationNotFoundException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.notification.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class NotificationNotFoundException extends ApplicationException { + public NotificationNotFoundException() { + super(ErrorCode.NOTIFICATION_NOT_FOUND); + } +} diff --git a/src/main/java/com/campus/campus/domain/notification/application/mapper/NotificationMapper.java b/src/main/java/com/campus/campus/domain/notification/application/mapper/NotificationMapper.java new file mode 100644 index 00000000..9c4f4fa6 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/application/mapper/NotificationMapper.java @@ -0,0 +1,41 @@ +package com.campus.campus.domain.notification.application.mapper; + +import org.springframework.stereotype.Component; + +import com.campus.campus.domain.notification.application.dto.NotificationResponse; +import com.campus.campus.domain.notification.domain.entity.Notification; +import com.campus.campus.domain.notification.domain.entity.NotificationType; +import com.campus.campus.domain.notification.util.TimeFormatter; +import com.campus.campus.domain.user.domain.entity.User; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class NotificationMapper { + + private final TimeFormatter timeFormatter; + + public NotificationResponse toResponse(Notification notification) { + return new NotificationResponse( + notification.getId(), + notification.getType(), + notification.getTitle(), + notification.getBody(), + notification.getReferenceId(), + notification.isRead(), + timeFormatter.formatRelativeTime(notification.getCreatedAt()) + ); + } + + public Notification createNotification(User user, NotificationType type, + String title, String body, Long referenceId) { + return Notification.builder() + .user(user) + .type(type) + .title(title) + .body(body) + .referenceId(referenceId) + .build(); + } +} diff --git a/src/main/java/com/campus/campus/domain/notification/application/service/NotificationService.java b/src/main/java/com/campus/campus/domain/notification/application/service/NotificationService.java new file mode 100644 index 00000000..d905e98f --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/application/service/NotificationService.java @@ -0,0 +1,122 @@ +package com.campus.campus.domain.notification.application.service; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.campus.campus.domain.councilpost.application.dto.request.CouncilPostCreatedEvent; +import com.campus.campus.domain.notification.application.dto.CursorResponse; +import com.campus.campus.domain.notification.application.dto.NextCursor; +import com.campus.campus.domain.notification.application.dto.NotificationResponse; +import com.campus.campus.domain.notification.application.exception.NotificationAccessDeniedException; +import com.campus.campus.domain.notification.application.exception.NotificationNotFoundException; +import com.campus.campus.domain.notification.application.mapper.NotificationMapper; +import com.campus.campus.domain.notification.domain.entity.Notification; +import com.campus.campus.domain.notification.domain.entity.NotificationType; +import com.campus.campus.domain.notification.domain.repository.NotificationRepository; +import com.campus.campus.domain.user.application.exception.UserNotFoundException; +import com.campus.campus.domain.user.domain.entity.User; +import com.campus.campus.domain.user.domain.repository.UserRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final NotificationMapper notificationMapper; + private final UserRepository userRepository; + + public CursorResponse getNotificationsByCursor( + Long userId, + LocalDateTime cursorCreatedAt, + Long cursorId, + int limit + ) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(UserNotFoundException::new); + + int pageSize = Math.min(Math.max(limit, 1), 50); + Pageable pageable = PageRequest.of(0, pageSize); + + boolean isFirst = (cursorCreatedAt == null || cursorId == null); + + List list = isFirst + ? notificationRepository.findByUserOrderByCreatedAtDescIdDesc(user, pageable) + : notificationRepository.findNextByCursor(user, cursorCreatedAt, cursorId, pageable); + + List items = list.stream() + .map(notificationMapper::toResponse) + .toList(); + + boolean hasNext = list.size() == pageSize; + + NextCursor nextCursor = null; + if (!list.isEmpty()) { + Notification last = list.get(list.size() - 1); + nextCursor = new NextCursor(last.getCreatedAt(), last.getId()); + } + + return new CursorResponse<>(items, nextCursor, hasNext); + } + + @Transactional + public void markAsRead(Long userId, Long notificationId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(UserNotFoundException::new); + + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(NotificationNotFoundException::new); + + if (!notification.getUser().getId().equals(user.getId())) { + throw new NotificationAccessDeniedException(); + } + + notification.markAsRead(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void savePostCreatedNotification(CouncilPostCreatedEvent event, String title, String body) { + + List targetUsers = findUsersByTopic(event.topic()); + + List notifications = targetUsers.stream() + .map(user -> notificationMapper.createNotification( + user, + NotificationType.COUNCIL_POST_CREATED, + title, + body, + event.postId() + )) + .toList(); + + notificationRepository.saveAll(notifications); + } + + @Transactional(readOnly = true) + public boolean hasUnread(Long userId) { + return notificationRepository.existsByUser_IdAndIsReadFalse(userId); + } + + private List findUsersByTopic(String topic) { + + String[] parts = topic.split("_"); + String scope = parts[0]; + Long scopeId = Long.valueOf(parts[1]); + + return switch (scope) { + case "major" -> userRepository.findAllByMajor_MajorIdAndDeletedAtIsNull(scopeId); + case "college" -> userRepository.findAllByCollege_CollegeIdAndDeletedAtIsNull(scopeId); + case "school" -> userRepository.findAllBySchool_SchoolIdAndDeletedAtIsNull(scopeId); + default -> List.of(); + }; + } +} diff --git a/src/main/java/com/campus/campus/domain/notification/domain/entity/Notification.java b/src/main/java/com/campus/campus/domain/notification/domain/entity/Notification.java new file mode 100644 index 00000000..c8f24fc6 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/domain/entity/Notification.java @@ -0,0 +1,69 @@ +package com.campus.campus.domain.notification.domain.entity; + +import java.time.LocalDateTime; + +import com.campus.campus.domain.user.domain.entity.User; +import com.campus.campus.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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationType type; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String body; + + @Column(name = "reference_id") + private Long referenceId; // postId, commentId 등 + + @Column(nullable = false) + private boolean isRead = false; + + @Column(name = "read_at") + private LocalDateTime readAt; + + @Builder + public Notification(User user, NotificationType type, String title, + String body, Long referenceId) { + this.user = user; + this.type = type; + this.title = title; + this.body = body; + this.referenceId = referenceId; + } + + public void markAsRead() { + this.isRead = true; + this.readAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/campus/campus/domain/notification/domain/entity/NotificationType.java b/src/main/java/com/campus/campus/domain/notification/domain/entity/NotificationType.java new file mode 100644 index 00000000..cf3a0a50 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/domain/entity/NotificationType.java @@ -0,0 +1,6 @@ +package com.campus.campus.domain.notification.domain.entity; + +public enum NotificationType { + COUNCIL_POST_CREATED, + SYSTEM_NOTICE +} diff --git a/src/main/java/com/campus/campus/domain/notification/domain/repository/NotificationRepository.java b/src/main/java/com/campus/campus/domain/notification/domain/repository/NotificationRepository.java new file mode 100644 index 00000000..3c367113 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/domain/repository/NotificationRepository.java @@ -0,0 +1,35 @@ +package com.campus.campus.domain.notification.domain.repository; + +import java.time.LocalDateTime; +import java.util.List; + +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; + +import com.campus.campus.domain.notification.domain.entity.Notification; +import com.campus.campus.domain.user.domain.entity.User; + +public interface NotificationRepository extends JpaRepository { + + List findByUserOrderByCreatedAtDescIdDesc(User user, Pageable pageable); + + @Query(""" + select n from Notification n + where n.user = :user + and ( + n.createdAt < :cursorCreatedAt + or (n.createdAt = :cursorCreatedAt and n.id < :cursorId) + ) + order by n.createdAt desc, n.id desc + """) + List findNextByCursor( + @Param("user") User user, + @Param("cursorCreatedAt") LocalDateTime cursorCreatedAt, + @Param("cursorId") Long cursorId, + Pageable pageable + ); + + boolean existsByUser_IdAndIsReadFalse(Long userId); +} diff --git a/src/main/java/com/campus/campus/domain/notification/presentation/NotificationController.java b/src/main/java/com/campus/campus/domain/notification/presentation/NotificationController.java new file mode 100644 index 00000000..175beacb --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/presentation/NotificationController.java @@ -0,0 +1,79 @@ +package com.campus.campus.domain.notification.presentation; + +import java.time.LocalDateTime; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.campus.campus.domain.notification.application.dto.CursorResponse; +import com.campus.campus.domain.notification.application.dto.NotificationResponse; +import com.campus.campus.domain.notification.application.service.NotificationService; +import com.campus.campus.global.annotation.CurrentUserId; +import com.campus.campus.global.common.response.CommonResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/notifications") +@RequiredArgsConstructor +@Tag(name = "알림", description = "알림 관련 API") +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping + @Operation( + summary = "알림 목록 조회", + description = "사용자의 알림 목록을 최신순으로 조회합니다." + ) + public CommonResponse> getNotifications( + @RequestParam(defaultValue = "20") int limit, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime cursorCreatedAt, + @RequestParam(required = false) Long cursorId, + @CurrentUserId Long userId + ) { + CursorResponse response = + notificationService.getNotificationsByCursor(userId, cursorCreatedAt, cursorId, limit); + + return CommonResponse.success(NotificationResponseCode.NOTIFICATION_LIST_READ_SUCCESS, response); + } + + @PatchMapping("/{notificationId}/read") + @Operation( + summary = "특정 알림 읽음 처리", + description = "특정 알림을 읽음 상태로 변경합니다." + ) + public CommonResponse markAsRead( + @PathVariable Long notificationId, + @CurrentUserId Long userId + ) { + notificationService.markAsRead(userId, notificationId); + return CommonResponse.success(NotificationResponseCode.NOTIFICATION_READ_SUCCESS); + } + + @GetMapping("/unread/exists") + @Operation( + summary = "미확인 알림 여부 확인", + description = "홈에서 알림 아이콘에 빨간 점을 표시하기 위해 미확인 알림이 있는지 확인합니다." + ) + public CommonResponse hasUnread(@CurrentUserId Long userId) { + + boolean hasUnread = notificationService.hasUnread(userId); + + return CommonResponse.success( + NotificationResponseCode.NOTIFICATION_UNREAD_EXISTS_SUCCESS, + hasUnread + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/notification/presentation/NotificationResponseCode.java b/src/main/java/com/campus/campus/domain/notification/presentation/NotificationResponseCode.java new file mode 100644 index 00000000..871b56e5 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/presentation/NotificationResponseCode.java @@ -0,0 +1,24 @@ +package com.campus.campus.domain.notification.presentation; + +import org.springframework.http.HttpStatus; + +import com.campus.campus.global.common.response.ResponseCodeInterface; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationResponseCode implements ResponseCodeInterface { + + NOTIFICATION_LIST_READ_SUCCESS(200, HttpStatus.OK, "알림 목록 조회 성공"), + UNREAD_COUNT_READ_SUCCESS(200, HttpStatus.OK, "읽지 않은 알림 개수 조회 성공"), + NOTIFICATION_READ_SUCCESS(200, HttpStatus.OK, "알림 읽음 처리 성공"), + ALL_NOTIFICATIONS_READ_SUCCESS(200, HttpStatus.OK, "모든 알림 읽음 처리 성공"), + NOTIFICATIONS_READ_SUCCESS(200, HttpStatus.OK, "선택 알림 읽음 처리 성공"), + NOTIFICATION_UNREAD_EXISTS_SUCCESS(200, HttpStatus.OK, "미확인 알림 존재 여부 조회에 성공했습니다."); + + private final int code; + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/campus/campus/domain/notification/util/TimeFormatter.java b/src/main/java/com/campus/campus/domain/notification/util/TimeFormatter.java new file mode 100644 index 00000000..3bc97e91 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/notification/util/TimeFormatter.java @@ -0,0 +1,53 @@ +package com.campus.campus.domain.notification.util; + +import java.time.Duration; +import java.time.LocalDateTime; + +import org.springframework.stereotype.Component; + +@Component +public class TimeFormatter { + + public String formatRelativeTime(LocalDateTime dateTime) { + if (dateTime == null) { + return null; + } + + LocalDateTime now = LocalDateTime.now(); + Duration duration = Duration.between(dateTime, now); + + long seconds = duration.getSeconds(); + + if (seconds < 60) { + return "방금 전"; + } + + long minutes = seconds / 60; + if (minutes < 60) { + return minutes + "분 전"; + } + + long hours = minutes / 60; + if (hours < 24) { + return hours + "시간 전"; + } + + long days = hours / 24; + if (days < 7) { + return days + "일 전"; + } + + long weeks = days / 7; + if (weeks < 4) { + return weeks + "주 전"; + } + + long months = days / 30; + if (months < 12) { + return months + "개월 전"; + } + + long years = days / 365; + return years + "년 전"; + } +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/user/domain/repository/UserRepository.java b/src/main/java/com/campus/campus/domain/user/domain/repository/UserRepository.java index 3e44b996..b2e04a30 100644 --- a/src/main/java/com/campus/campus/domain/user/domain/repository/UserRepository.java +++ b/src/main/java/com/campus/campus/domain/user/domain/repository/UserRepository.java @@ -42,4 +42,11 @@ SELECT u, COUNT(s) GROUP BY u """) List findRewardNeededUsersWithStampCount(); + + List findAllByMajor_MajorIdAndDeletedAtIsNull(Long majorId); + + List findAllByCollege_CollegeIdAndDeletedAtIsNull(Long collegeId); + + List findAllBySchool_SchoolIdAndDeletedAtIsNull(Long schoolId); + }