Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import run.backend.domain.crew.dto.response.EventProfileResponse;
import run.backend.domain.event.entity.Event;
import run.backend.domain.event.entity.JoinEvent;
import run.backend.domain.event.enums.EventStatus;
import run.backend.domain.member.entity.Member;

public interface JoinEventRepository extends JpaRepository<JoinEvent, Long> {
Expand All @@ -19,26 +20,28 @@ public interface JoinEventRepository extends JpaRepository<JoinEvent, Long> {
@Query("SELECT j FROM JoinEvent j WHERE j.event = :event")
List<JoinEvent> findByEvent(@Param("event") Event event);

@Query("SELECT j FROM JoinEvent j WHERE j.event = :event AND j.event.status = 'COMPLETED'")
List<JoinEvent> findActualParticipantsByEvent(@Param("event") Event event);
@Query("SELECT j FROM JoinEvent j WHERE j.event = :event AND j.event.status = :status")
List<JoinEvent> findActualParticipantsByEvent(@Param("event") Event event, @Param("status") EventStatus status);

@Query("SELECT j FROM JoinEvent j WHERE j.member = :member " +
"AND j.event.date >= :startDate AND j.event.date <= :endDate " +
"AND j.event.status = 'COMPLETED'")
"AND j.event.status = :status")
List<JoinEvent> findMonthlyParticipatedEvents(@Param("member") Member member,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
@Param("endDate") LocalDate endDate,
@Param("status") EventStatus status);

@Query("""
SELECT new run.backend.domain.crew.dto.response.EventProfileResponse(\
e.id, e.title, e.date, e.startTime, e.endTime, e.expectedParticipants) \
FROM JoinEvent j JOIN j.event e \
WHERE j.member = :member \
AND e.date >= :startDate AND e.date <= :endDate \
AND e.status = 'COMPLETED' ORDER BY e.date DESC""")
AND e.status = :status ORDER BY e.date DESC""")
List<EventProfileResponse> findMonthlyCompletedEvents(@Param("member") Member member,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
@Param("endDate") LocalDate endDate,
@Param("status") EventStatus status);

boolean existsByEventAndMember(Event event, Member member);
}
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ public EventDetailResponse getEventDetail(Long eventId) {

private List<JoinEvent> getParticipants(Event event, EventStatus status) {
return status == EventStatus.COMPLETED
? joinEventRepository.findActualParticipantsByEvent(event)
? joinEventRepository.findActualParticipantsByEvent(event, EventStatus.COMPLETED)
: joinEventRepository.findByEvent(event);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import run.backend.domain.crew.enums.JoinStatus;
import run.backend.domain.crew.repository.JoinCrewRepository;
import run.backend.domain.event.entity.JoinEvent;
import run.backend.domain.event.enums.EventStatus;
import run.backend.domain.event.repository.JoinEventRepository;
import run.backend.domain.file.service.FileService;
import run.backend.domain.member.dto.request.MemberInfoRequest;
Expand Down Expand Up @@ -76,7 +77,7 @@ public MemberParticipatedCountResponse getParticipatedEventCount(Member member)
DateRange monthRange = dateRangeUtil.getMonthRange(today.getYear(), today.getMonthValue());

List<JoinEvent> monthlyJoinEvents = joinEventRepository.findMonthlyParticipatedEvents(
member, monthRange.start(), monthRange.end());
member, monthRange.start(), monthRange.end(), EventStatus.COMPLETED);

Long participatedCount = (long) monthlyJoinEvents.size();
return new MemberParticipatedCountResponse(participatedCount);
Expand All @@ -88,7 +89,7 @@ public EventResponseDto getParticipatedEvent(Member member) {
DateRange monthRange = dateRangeUtil.getMonthRange(today.getYear(), today.getMonthValue());

List<EventProfileResponse> eventProfiles = joinEventRepository.findMonthlyCompletedEvents(
member, monthRange.start(), monthRange.end());
member, monthRange.start(), monthRange.end(), EventStatus.COMPLETED);

return new EventResponseDto(eventProfiles);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package run.backend.domain.notification.controller;

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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import run.backend.domain.member.entity.Member;
import run.backend.domain.notification.dto.NotificationResponse;
import run.backend.domain.notification.service.NotificationService;
import run.backend.global.annotation.member.Login;
import run.backend.global.common.response.CommonResponse;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/notifications")
@Tag(name = "알림 API", description = "알림 관련 API")
public class NotificationController {

private final NotificationService notificationService;

@GetMapping
@Operation(summary = "알림 조회", description = "사용자의 알림 목록을 조회합니다. type 파라미터로 전체, 크루, 대결 알림을 필터링할 수 있습니다.")
public ResponseEntity<CommonResponse<NotificationResponse>> getNotifications(
@Parameter(description = "알림 타입 (all, crew, battle)", example = "all") @RequestParam(value = "type", defaultValue = "all") String type,
@Login Member member) {
NotificationResponse response = notificationService.getNotifications(member, type);
return ResponseEntity.ok(new CommonResponse<>("알림 조회 성공", response));
}

@PostMapping("/{notificationId}/read")
@Operation(summary = "알림 읽기 요청", description = "특정 알림을 읽음 상태로 변경합니다.")
public ResponseEntity<CommonResponse<Void>> markAsRead(
@Parameter(description = "알림 ID", example = "1") @PathVariable Long notificationId,
@Login Member member) {
notificationService.markAsRead(notificationId, member);
return ResponseEntity.ok(new CommonResponse<>("알림 읽기 처리 완료"));
}

@PostMapping("/read")
@Operation(summary = "알림 모두 읽기 요청", description = "모든 읽지 않은 알림을 읽음 상태로 변경합니다.")
public ResponseEntity<CommonResponse<Void>> markAllAsRead(@Login Member member) {
notificationService.markAllAsRead(member);
return ResponseEntity.ok(new CommonResponse<>("모든 알림 읽기 처리 완료"));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package run.backend.domain.notification.dto;

public record NotificationItem(String notificationId, String type, String message, String timeAgo) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package run.backend.domain.notification.dto;

import java.util.List;

public record NotificationResponse(List<NotificationItem> read, List<NotificationItem> unread) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.SQLDelete;
import run.backend.domain.member.entity.Member;
import run.backend.domain.notification.enums.MessageType;
import run.backend.global.common.BaseEntity;
Expand All @@ -13,6 +14,7 @@
@Getter
@Table(name = "notifications")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SQLDelete(sql = "UPDATE notifications SET deleted_at = NOW() WHERE id = ?")
public class Notification extends BaseEntity {

@Id
Expand Down Expand Up @@ -54,4 +56,8 @@ public Notification(
this.sender = sender;
this.receiver = receiver;
}

public void markAsRead() {
this.isRead = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
@Getter
public enum MessageType {

ALL("전체"),
BATTLE("대결"),
CREW("크루");

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package run.backend.domain.notification.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;
import run.backend.global.exception.ErrorCode;

@Getter
@AllArgsConstructor
public enum NotificationErrorCode implements ErrorCode {

INVALID_NOTIFICATION_TYPE(6001, "유효하지 않은 알림 타입입니다."),
NOTIFICATION_NOT_FOUND(6002, "존재하지 않는 알림입니다.");

private final int errorCode;
private final String errorMessage;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package run.backend.domain.notification.exception;

import run.backend.global.exception.CustomException;

public class NotificationException extends CustomException {

public NotificationException(final NotificationErrorCode notificationErrorCode) {
super(notificationErrorCode);
}

public static class InvalidNotificationType extends NotificationException {
public InvalidNotificationType() {
super(NotificationErrorCode.INVALID_NOTIFICATION_TYPE);
}
}

public static class NotificationNotFound extends NotificationException {
public NotificationNotFound() {
super(NotificationErrorCode.NOTIFICATION_NOT_FOUND);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package run.backend.domain.notification.mapper;

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ReportingPolicy;
import run.backend.domain.notification.dto.NotificationItem;
import run.backend.domain.notification.entity.Notification;

@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.IGNORE
)
public interface NotificationMapper {

@Mapping(target = "notificationId", source = "id")
@Mapping(target = "type", source = "messageType.description")
@Mapping(target = "timeAgo", expression = "java(calculateTimeAgo(notification.getCreatedAt()))")
NotificationItem toNotificationItem(Notification notification);

List<NotificationItem> toNotificationItemList(List<Notification> notifications);

default String calculateTimeAgo(LocalDateTime createdAt) {
LocalDateTime now = LocalDateTime.now();
long minutes = ChronoUnit.MINUTES.between(createdAt, now);
long hours = ChronoUnit.HOURS.between(createdAt, now);
long days = ChronoUnit.DAYS.between(createdAt, now);

if (minutes < 60) {
return minutes + "분 전";
} else if (hours < 24) {
return hours + "시간 전";
} else {
return days + "일 전";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package run.backend.domain.notification.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import run.backend.domain.member.entity.Member;
import run.backend.domain.notification.entity.Notification;
import run.backend.domain.notification.enums.MessageType;

import java.util.List;
import java.util.Optional;

public interface NotificationRepository extends JpaRepository<Notification, Long> {

Optional<Notification> findByIdAndReceiver(Long id, Member receiver);

@Query("SELECT n FROM Notification n WHERE n.receiver = :member AND n.isRead = true ORDER BY n.createdAt DESC")
List<Notification> findReadNotificationsByMember(@Param("member") Member member);

@Query("SELECT n FROM Notification n WHERE n.receiver = :member AND n.isRead = false ORDER BY n.createdAt DESC")
List<Notification> findUnreadNotificationsByMember(@Param("member") Member member);

@Query("SELECT n FROM Notification n WHERE n.receiver = :member AND n.messageType = :messageType AND n.isRead = true ORDER BY n.createdAt DESC")
List<Notification> findReadNotificationsByMemberAndType(@Param("member") Member member, @Param("messageType") MessageType messageType);

@Query("SELECT n FROM Notification n WHERE n.receiver = :member AND n.messageType = :messageType AND n.isRead = false ORDER BY n.createdAt DESC")
List<Notification> findUnreadNotificationsByMemberAndType(@Param("member") Member member, @Param("messageType") MessageType messageType);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package run.backend.domain.notification.service;

import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import run.backend.domain.member.entity.Member;
import run.backend.domain.notification.dto.NotificationItem;
import run.backend.domain.notification.dto.NotificationResponse;
import run.backend.domain.notification.entity.Notification;
import run.backend.domain.notification.enums.MessageType;
import run.backend.domain.notification.exception.NotificationException;
import run.backend.domain.notification.exception.NotificationException.NotificationNotFound;
import run.backend.domain.notification.mapper.NotificationMapper;
import run.backend.domain.notification.repository.NotificationRepository;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class NotificationService {

private final NotificationRepository notificationRepository;
private final NotificationMapper notificationMapper;

public NotificationResponse getNotifications(Member member, String type) {
MessageType messageType = convertStringToMessageType(type);

List<Notification> readNotifications;
List<Notification> unreadNotifications;

if (messageType == MessageType.ALL) {
readNotifications = notificationRepository.findReadNotificationsByMember(member);
unreadNotifications = notificationRepository.findUnreadNotificationsByMember(member);
} else {
readNotifications = notificationRepository.findReadNotificationsByMemberAndType(member, messageType);
unreadNotifications = notificationRepository.findUnreadNotificationsByMemberAndType(member, messageType);
}

List<NotificationItem> readItems = notificationMapper.toNotificationItemList(readNotifications);
List<NotificationItem> unreadItems = notificationMapper.toNotificationItemList(unreadNotifications);

return new NotificationResponse(readItems, unreadItems);
}

@Transactional
public void markAsRead(Long notificationId, Member member) {
Notification notification = notificationRepository.findByIdAndReceiver(notificationId, member)
.orElseThrow(NotificationNotFound::new);

notification.markAsRead();
}

@Transactional
public void markAllAsRead(Member member) {
List<Notification> unreadNotifications = notificationRepository.findUnreadNotificationsByMember(member);
unreadNotifications.forEach(Notification::markAsRead);
}

private MessageType convertStringToMessageType(String type) {
return switch (type.toLowerCase()) {
case "all" -> MessageType.ALL;
case "crew" -> MessageType.CREW;
case "battle" -> MessageType.BATTLE;
default -> throw new NotificationException.InvalidNotificationType();
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import run.backend.domain.event.exception.EventException;
import run.backend.domain.file.exception.FileException;
import run.backend.domain.member.exception.MemberException;
import run.backend.domain.notification.exception.NotificationException;
import run.backend.global.common.response.CommonResponse;
import run.backend.global.exception.httpError.HttpErrorCode;

Expand All @@ -26,7 +27,8 @@ public class GlobalExceptionHandler {
MemberException.MemberNotFound.class,
CrewException.NotFoundCrew.class,
EventException.EventNotFound.class,
EventException.JoinEventNotFound.class
EventException.JoinEventNotFound.class,
NotificationException.NotificationNotFound.class
})
public ResponseEntity<CommonResponse<Void>> handleNotFound(final CustomException e) {

Expand Down Expand Up @@ -62,7 +64,8 @@ public ResponseEntity<CommonResponse<Void>> handleConflict(final CustomException
AuthException.InvalidRefreshToken.class,
AuthException.RefreshTokenExpired.class,
FileException.FileUploadFailed.class,
EventException.InvalidEventCreationRequest.class
EventException.InvalidEventCreationRequest.class,
NotificationException.InvalidNotificationType.class
})
public ResponseEntity<CommonResponse<Void>> handleBadRequest(final CustomException e) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ public class SecurityConfig {
"/api/v1/members/**",
"/api/v1/auth/**",
"/api/v1/events/**",
"/api/v1/crews/**"
"/api/v1/crews/**",
"/api/v1/notifications"
};

@Bean
Expand Down
Loading