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 @@ -36,61 +36,110 @@ public class NotificationController {
private final UserRepository userRepository;
private final RoomRepository roomRepository;

@Operation(summary = "알림 전송", description = "USER/ROOM/COMMUNITY/SYSTEM 타입별 알림 생성 및 전송")
@Operation(
summary = "알림 전송",
description = "USER/ROOM/COMMUNITY/SYSTEM 타입별 알림 생성 및 전송\n\n" +
"- USER: 개인 알림 (actorId, targetId 필수)\n" +
"- ROOM: 스터디룸 알림 (actorId, targetId(roomId) 필수)\n" +
"- COMMUNITY: 커뮤니티 알림 (actorId, targetId 필수)\n" +
"- SYSTEM: 시스템 전체 알림 (actorId, targetId 불필요)"
)
@PostMapping
public ResponseEntity<RsData<NotificationResponse>> createNotification(
@Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails,
@RequestBody NotificationCreateRequest request) {

log.info("알림 전송 요청 - 타입: {}, 제목: {}", request.targetType(), request.title());

// targetType 검증
if (!isValidTargetType(request.targetType())) {
throw new CustomException(ErrorCode.NOTIFICATION_INVALID_TARGET_TYPE);
}

// SYSTEM이 아닌 경우 필수 필드 검증
if (!"SYSTEM".equals(request.targetType())) {
if (request.actorId() == null) {
throw new CustomException(ErrorCode.NOTIFICATION_MISSING_ACTOR);
}
if (request.targetId() == null) {
throw new CustomException(ErrorCode.NOTIFICATION_MISSING_TARGET);
}
}

Notification notification = switch (request.targetType()) {
case "USER" -> {
User targetUser = userRepository.findById(request.targetId())
// 수신자 조회
User receiver = userRepository.findById(request.targetId())
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

// 발신자 조회
User actor = userRepository.findById(request.actorId())
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

yield notificationService.createPersonalNotification(
targetUser,
receiver,
actor,
request.title(),
request.message(),
request.redirectUrl()
);
}
case "ROOM" -> {
// 스터디룸 조회
Room room = roomRepository.findById(request.targetId())
.orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND));

// 발신자 조회
User actor = userRepository.findById(request.actorId())
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

yield notificationService.createRoomNotification(
room,
actor,
request.title(),
request.message(),
request.redirectUrl()
);
}
case "COMMUNITY" -> {
User targetUser = userRepository.findById(request.targetId())
// 수신자 조회 (리뷰/게시글 작성자)
User receiver = userRepository.findById(request.targetId())
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

// 발신자 조회 (댓글/좋아요 작성자)
User actor = userRepository.findById(request.actorId())
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

yield notificationService.createCommunityNotification(
targetUser,
receiver,
actor,
request.title(),
request.message(),
request.redirectUrl()
);
}
case "SYSTEM" -> {
// 시스템 알림은 발신자/수신자 없음
yield notificationService.createSystemNotification(
request.title(),
request.message(),
request.redirectUrl()
);
}
default -> throw new IllegalArgumentException("유효하지 않은 알림 타입입니다: " + request.targetType());
default -> throw new CustomException(ErrorCode.NOTIFICATION_INVALID_TARGET_TYPE);
};

NotificationResponse response = NotificationResponse.from(notification);

return ResponseEntity.ok(RsData.success("알림 전송 성공", response));
}

@Operation(summary = "알림 목록 조회", description = "사용자의 알림 목록 조회 (페이징)")
@Operation(
summary = "알림 목록 조회",
description = "사용자의 알림 목록 조회 (페이징)\n\n" +
"- unreadOnly=true: 읽지 않은 알림만\n" +
"- unreadOnly=false: 모든 알림"
)
@GetMapping
public ResponseEntity<RsData<NotificationListResponse>> getNotifications(
@Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails,
Expand Down Expand Up @@ -121,7 +170,11 @@ public ResponseEntity<RsData<NotificationListResponse>> getNotifications(
return ResponseEntity.ok(RsData.success("알림 목록 조회 성공", response));
}

@Operation(summary = "알림 읽음 처리", description = "특정 알림을 읽음 상태로 변경")
@Operation(
summary = "알림 읽음 처리",
description = "특정 알림을 읽음 상태로 변경\n\n" +
"이미 읽은 알림일 경우 NOTIFICATION_ALREADY_READ 에러 반환"
)
@PutMapping("/{notificationId}/read")
public ResponseEntity<RsData<NotificationResponse>> markAsRead(
@Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails,
Expand All @@ -135,12 +188,19 @@ public ResponseEntity<RsData<NotificationResponse>> markAsRead(
notificationService.markAsRead(notificationId, user);

Notification notification = notificationService.getNotification(notificationId);
NotificationResponse response = NotificationResponse.from(notification);
boolean isRead = notificationService.isNotificationRead(notificationId, user.getId());

// readAt 조회 (NotificationRead에서)
NotificationResponse response = NotificationResponse.from(notification, isRead,
isRead ? java.time.LocalDateTime.now() : null);

return ResponseEntity.ok(RsData.success("알림 읽음 처리 성공", response));
}

@Operation(summary = "모든 알림 읽음 처리", description = "사용자의 읽지 않은 모든 알림을 읽음 상태로 변경")
@Operation(
summary = "모든 알림 읽음 처리",
description = "사용자의 읽지 않은 모든 알림을 읽음 상태로 변경"
)
@PutMapping("/read-all")
public ResponseEntity<RsData<Void>> markAllAsRead(
@Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails) {
Expand All @@ -154,4 +214,14 @@ public ResponseEntity<RsData<Void>> markAllAsRead(

return ResponseEntity.ok(RsData.success("전체 알림 읽음 처리 성공"));
}

// ==================== 헬퍼 메서드 ====================

private boolean isValidTargetType(String targetType) {
return targetType != null &&
(targetType.equals("USER") ||
targetType.equals("ROOM") ||
targetType.equals("COMMUNITY") ||
targetType.equals("SYSTEM"));
}
}
18 changes: 18 additions & 0 deletions src/main/java/com/back/domain/notification/dto/ActorDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.back.domain.notification.dto;

import com.back.domain.user.entity.User;

public record ActorDto(
Long userId,
String username,
String profileImageUrl
) {
public static ActorDto from(User user) {
if (user == null) return null;
return new ActorDto(
user.getId(),
user.getNickname(),
user.getProfileImageUrl()
);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
package com.back.domain.notification.dto;

public record NotificationCreateRequest(
String targetType, // USER, ROOM, SYSTEM
Long targetId, // nullable (SYSTEM일 때 null)
String targetType,
Long targetId,
Long actorId,
String title,
String message,
String notificationType, // STUDY_REMINDER, ROOM_JOIN 등
String redirectUrl, // targetUrl
String scheduleType, // ONE_TIME (향후 확장용)
String scheduledAt // 예약 시간 (향후 확장용)
) {
}
String redirectUrl
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public record NotificationItemDto(
NotificationType notificationType,
String targetUrl,
boolean isRead,
ActorDto actor,
LocalDateTime createdAt
) {
public static NotificationItemDto from(Notification notification, boolean isRead) {
Expand All @@ -25,6 +26,7 @@ public static NotificationItemDto from(Notification notification, boolean isRead
notification.getType(),
notification.getTargetUrl(),
isRead,
ActorDto.from(notification.getActor()),
notification.getCreatedAt()
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ public record NotificationListResponse(
long unreadCount
) {

// 페이지 정보 DTO
public record PageableDto(
int page,
int size,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import java.time.LocalDateTime;

/**
* 알림 응답 DTO
* 알림 상세 응답 DTO
*/
public record NotificationResponse(
Long notificationId,
Expand All @@ -15,6 +15,7 @@ public record NotificationResponse(
NotificationType notificationType,
String targetUrl,
boolean isRead,
ActorDto actor,
LocalDateTime createdAt,
LocalDateTime readAt
) {
Expand All @@ -25,9 +26,10 @@ public static NotificationResponse from(Notification notification) {
notification.getContent(),
notification.getType(),
notification.getTargetUrl(),
false, // 읽음 여부는 NotificationListResponse에서 처리
false,
ActorDto.from(notification.getActor()),
notification.getCreatedAt(),
null // readAt은 NotificationRead에서 가져와야 함
null
);
}

Expand All @@ -39,6 +41,7 @@ public static NotificationResponse from(Notification notification, boolean isRea
notification.getType(),
notification.getTargetUrl(),
isRead,
ActorDto.from(notification.getActor()),
notification.getCreatedAt(),
readAt
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.back.domain.notification.dto;

import com.back.domain.notification.entity.Notification;
import com.back.domain.notification.entity.NotificationType;

import java.time.LocalDateTime;
Expand All @@ -10,24 +11,18 @@ public record NotificationWebSocketDto(
String message,
NotificationType notificationType,
String targetUrl,
ActorDto actor,
LocalDateTime createdAt
) {

public static NotificationWebSocketDto from(
Long notificationId,
String title,
String content,
NotificationType type,
String targetUrl,
LocalDateTime createdAt) {

public static NotificationWebSocketDto from(Notification notification) {
return new NotificationWebSocketDto(
notificationId,
title,
content,
type,
targetUrl,
createdAt
notification.getId(),
notification.getTitle(),
notification.getContent(),
notification.getType(),
notification.getTargetUrl(),
ActorDto.from(notification.getActor()),
notification.getCreatedAt()
);
}
}
Loading