diff --git a/backend/src/main/java/org/example/backend/domain/lecture/entity/Lecture.java b/backend/src/main/java/org/example/backend/domain/lecture/entity/Lecture.java index 9ab2fc11..7301ade2 100644 --- a/backend/src/main/java/org/example/backend/domain/lecture/entity/Lecture.java +++ b/backend/src/main/java/org/example/backend/domain/lecture/entity/Lecture.java @@ -10,6 +10,7 @@ import org.example.backend.domain.classroom.entity.Classroom; import org.example.backend.domain.lectureNoteMapping.entity.LectureNoteMapping; +import org.example.backend.domain.notification.entity.Notification; import org.example.backend.domain.question.entity.Question; import org.example.backend.domain.quiz.entity.Quiz; import org.example.backend.domain.studentClass.entity.StudentClass; @@ -65,4 +66,7 @@ public class Lecture extends BaseEntity { @OneToMany(mappedBy = "lecture", cascade = CascadeType.ALL, orphanRemoval = true) private List questions = new ArrayList<>(); + @OneToMany(mappedBy = "lecture", cascade = CascadeType.ALL, orphanRemoval = true) + private List notification = new ArrayList<>(); + } \ No newline at end of file diff --git a/backend/src/main/java/org/example/backend/domain/lecture/service/LectureScheduler.java b/backend/src/main/java/org/example/backend/domain/lecture/service/LectureScheduler.java index 39048ec4..9ff62cc3 100644 --- a/backend/src/main/java/org/example/backend/domain/lecture/service/LectureScheduler.java +++ b/backend/src/main/java/org/example/backend/domain/lecture/service/LectureScheduler.java @@ -37,6 +37,13 @@ public void notifyProfessorBeforeLecture() { "시스템", lecture.getLectureName() + " 강의가 10분 후 시작됩니다." ); + + notificationService.sendAlarmToAllStudentsInLecture( + lecture.getId(), + AlarmType.startLecture, + "시스템", + lecture.getLectureName() + " 수업이 10분 후 시작됩니다." + ); } } } \ No newline at end of file diff --git a/backend/src/main/java/org/example/backend/domain/lectureNote/service/LectureNoteServiceImpl.java b/backend/src/main/java/org/example/backend/domain/lectureNote/service/LectureNoteServiceImpl.java index 19566053..63dd130f 100644 --- a/backend/src/main/java/org/example/backend/domain/lectureNote/service/LectureNoteServiceImpl.java +++ b/backend/src/main/java/org/example/backend/domain/lectureNote/service/LectureNoteServiceImpl.java @@ -13,9 +13,11 @@ import org.example.backend.domain.lectureNote.repository.LectureNoteRepository; import org.example.backend.domain.lectureNoteMapping.entity.LectureNoteMapping; import org.example.backend.domain.lectureNoteMapping.repository.LectureNoteMappingRepository; +import org.example.backend.domain.notification.service.NotificationService; import org.example.backend.global.S3.service.S3Service; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import org.example.backend.domain.notification.entity.AlarmType; import java.io.IOException; import java.util.ArrayList; @@ -29,22 +31,24 @@ public class LectureNoteServiceImpl implements LectureNoteService { private final S3Service s3Service; private final LectureNoteRepository lectureNoteRepository; - private final ClassroomRepository classroomRepository; // ✅ 추가 + private final ClassroomRepository classroomRepository; private final LectureNoteMappingRepository lectureNoteMappingRepository; + private final NotificationService notificationService; public List uploadLectureNotes(UUID classId, List files) throws IOException { // 1. 여러 파일에 대해 처리 List lectureNotes = new ArrayList<>(); + // 2) classId로 Classroom 엔티티 조회 + Classroom classroom = classroomRepository.findById(classId) + .orElseThrow(() -> new ClassroomException(ClassroomErrorCode.CLASS_NOT_FOUND)); + // 2. 각 파일 업로드 처리 for (MultipartFile file : files) { // 1) S3에 업로드 String key = "lecture_note/" + classId + "/" + UUID.randomUUID() + "/" + file.getOriginalFilename(); String fileUrl = s3Service.uploadFile(file, key); - // 2) classId로 Classroom 엔티티 조회 - Classroom classroom = classroomRepository.findById(classId) - .orElseThrow(() -> new ClassroomException(ClassroomErrorCode.CLASS_NOT_FOUND)); // 3) LectureNote 객체 생성 LectureNote lectureNote = LectureNote.builder() @@ -56,6 +60,13 @@ public List uploadLectureNotes(UUID classId, List fi lectureNotes.add(lectureNoteRepository.save(lectureNote)); } + notificationService.sendAlarmToAllStudentsInClass( + classId, + AlarmType.lectureNoteUpload, // enum에 없다면 추가 필요 + "시스템", + classroom.getClassName() + " 강의자료가 업로드되었습니다." + ); + return lectureNotes; } diff --git a/backend/src/main/java/org/example/backend/domain/notification/entity/Notification.java b/backend/src/main/java/org/example/backend/domain/notification/entity/Notification.java index 28d54d1f..47684cef 100644 --- a/backend/src/main/java/org/example/backend/domain/notification/entity/Notification.java +++ b/backend/src/main/java/org/example/backend/domain/notification/entity/Notification.java @@ -26,8 +26,8 @@ public class Notification extends BaseEntity { @JoinColumn(name = "user_id", nullable = false) private User user; - @ManyToOne - @JoinColumn(name = "lecture_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY, optional = true) + @JoinColumn(name = "lecture_id", nullable = true) private Lecture lecture; @Enumerated(EnumType.STRING) diff --git a/backend/src/main/java/org/example/backend/domain/notification/service/NotificationService.java b/backend/src/main/java/org/example/backend/domain/notification/service/NotificationService.java index 2c8bf0a4..df1b5796 100644 --- a/backend/src/main/java/org/example/backend/domain/notification/service/NotificationService.java +++ b/backend/src/main/java/org/example/backend/domain/notification/service/NotificationService.java @@ -1,6 +1,7 @@ package org.example.backend.domain.notification.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.example.backend.domain.classroom.entity.Classroom; import org.example.backend.domain.classroom.repository.ClassroomRepository; import org.example.backend.domain.lecture.entity.Lecture; @@ -12,6 +13,9 @@ import org.example.backend.domain.notification.repository.NotificationRepository; import org.example.backend.domain.notificationSetting.service.FcmService; import org.example.backend.domain.notificationSetting.service.NotificationTemplateService; +import org.example.backend.domain.studentClass.repository.StudentClassRepository; +import org.example.backend.domain.user.entity.User; +import org.example.backend.domain.user.repository.UserRepository; import org.example.backend.global.userdeviceToken.repository.UserDeviceTokenRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,6 +23,7 @@ import java.util.List; import java.util.UUID; +@Slf4j @RequiredArgsConstructor @Service public class NotificationService implements NotificationServiceImpl{ @@ -29,6 +34,8 @@ public class NotificationService implements NotificationServiceImpl{ private final NotificationTemplateService templateService; private final UserDeviceTokenRepository tokenRepository; private final FcmService fcmService; + private final StudentClassRepository studentClassRepository; + private final UserRepository userRepository; public List getNotificationsByUserId(UUID userId) { List notificationList = @@ -71,6 +78,137 @@ public void sendAlarmToProfessor(UUID lectureId, AlarmType type, String senderNa notificationRepository.save(notification); } + @Transactional + public void sendAlarmToStudentInLecture(UUID lectureId, UUID studentUserId, + AlarmType type, String senderName, String extra) { + Lecture lecture = lectureRepository.findById(lectureId) + .orElseThrow(() -> new RuntimeException("Lecture not found")); + + UUID classId = lecture.getClassroom().getId(); + + // 수강 검증 (해당 반의 학생인지) + if (!studentClassRepository.existsByClassIdAndUserId(classId, studentUserId)) { + throw new RuntimeException("Student does not belong to this class"); + } + + String title = templateService.getTitle(type); + String body = templateService.getBody(type, senderName, extra); + + sendToUser(studentUserId, title, body); + + User student = userRepository.findById(studentUserId) + .orElseThrow(() -> new RuntimeException("User not found")); + Notification notification = Notification.builder() + .user(student) + .lecture(lecture) + .alarmType(type) + .isRead(false) + .build(); + notificationRepository.save(notification); + } + + @Transactional + public void sendAlarmToAllStudentsInLecture(UUID lectureId, + AlarmType type, String senderName, String extra) { + Lecture lecture = lectureRepository.findById(lectureId) + .orElseThrow(() -> new RuntimeException("Lecture not found")); + + UUID classId = lecture.getClassroom().getId(); + + String title = templateService.getTitle(type); + String body = templateService.getBody(type, senderName, extra); + + // 이 반에 속한 모든 학생 userId + List studentIds = studentClassRepository.findUserIdsByClassId(classId); + + log.info("[Notification] classId={}, lectureId={}, studentCount={}", classId, lectureId, studentIds); + log.debug("[Notification] studentIds={}", studentIds); + + // (간단 버전) 각 유저별 토큰 찾아 전송 + studentIds.stream().distinct().forEach(id -> sendToUser(id, title, body)); + + List notifications = studentIds.stream() + .distinct() + .map(id -> Notification.builder() + .user(userRepository.getReferenceById(id)) + .lecture(lecture) + .alarmType(type) + .isRead(false) + .build() + ).toList(); + + notificationRepository.saveAll(notifications); + } + + /** 공통 전송 헬퍼: 유저의 활성 FCM 토큰 전부에 발송 */ + private void sendToUser(UUID userId, String title, String body) { + var tokens = tokenRepository.findAllByUserIdAndIsActiveTrue(userId); + tokens.forEach(token -> fcmService.sendNotification(token.getFcmToken(), title, body)); + } + @Transactional + public void sendAlarmToAllStudentsInClass(UUID classId, + AlarmType type, + String senderName, + String extra) { + // 1) 반 로딩 + Classroom classroom = classroomRepository.findById(classId) + .orElseThrow(() -> new RuntimeException("Classroom not found")); + + // 2) 템플릿 생성 + String title = templateService.getTitle(type); + String body = templateService.getBody(type, senderName, extra); + + // 3) 학생 userId 조회 (null 제거 + 중복 제거) + List studentIds = studentClassRepository.findUserIdsByClassId(classId) + .stream() + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + + log.info("[Notification] classId={}, studentCount={}", classId, studentIds.size()); + log.debug("[Notification] studentIds={}", studentIds); + + if (studentIds.isEmpty()) return; + + // 4) 푸시 발송 (유저별 활성 토큰에 전송) + studentIds.forEach(id -> sendToUser(id, title, body)); + + // 5) Notification 저장 (lecture는 클래스 단위이므로 null 가능해야 함) + var students = userRepository.findAllById(studentIds); + var userMap = students.stream().collect( + java.util.stream.Collectors.toMap( + org.example.backend.domain.user.entity.User::getId, + java.util.function.Function.identity() + ) + ); + + List notifications = studentIds.stream() + .map(id -> { + User u = userMap.get(id); + if (u == null) { + log.warn("[Notification] user not found for userId={} (skipped)", id); + return null; + } + return Notification.builder() + .user(u) + .lecture(null) // 특정 회차가 없으므로 null (엔티티가 nullable이어야 함) + .alarmType(type) + .isRead(false) + .build(); + }) + .filter(java.util.Objects::nonNull) + .toList(); + + notificationRepository.saveAll(notifications); + log.info("[Notification] saved notifications={}", notifications.size()); + } + + + + + + + @Transactional public void markAllAsRead(UUID userId) { notificationRepository.markAllAsReadByUserId(userId); diff --git a/backend/src/main/java/org/example/backend/domain/notificationSetting/entity/NotificationSetting.java b/backend/src/main/java/org/example/backend/domain/notificationSetting/entity/NotificationSetting.java index 09d8499d..b4a9ac5a 100644 --- a/backend/src/main/java/org/example/backend/domain/notificationSetting/entity/NotificationSetting.java +++ b/backend/src/main/java/org/example/backend/domain/notificationSetting/entity/NotificationSetting.java @@ -4,6 +4,8 @@ import lombok.*; import org.example.backend.global.entitiy.BaseEntity; +import java.util.UUID; + @Entity @Table(name = "notification_setting") @Data @@ -14,7 +16,7 @@ public class NotificationSetting extends BaseEntity { @Id @Column(name = "user_id") - private String userId; + private UUID userId; @Column(name = "quiz_upload", nullable = false) diff --git a/backend/src/main/java/org/example/backend/domain/notificationSetting/repository/NotificationSettingRepository.java b/backend/src/main/java/org/example/backend/domain/notificationSetting/repository/NotificationSettingRepository.java index fb989cf5..fe650dfe 100644 --- a/backend/src/main/java/org/example/backend/domain/notificationSetting/repository/NotificationSettingRepository.java +++ b/backend/src/main/java/org/example/backend/domain/notificationSetting/repository/NotificationSettingRepository.java @@ -3,5 +3,8 @@ import org.example.backend.domain.notificationSetting.entity.NotificationSetting; import org.springframework.data.jpa.repository.JpaRepository; -public interface NotificationSettingRepository extends JpaRepository { +import java.util.UUID; + +public interface NotificationSettingRepository extends JpaRepository { + boolean existsByUserId(UUID userId); } diff --git a/backend/src/main/java/org/example/backend/domain/notificationSetting/service/NotificationSettingService.java b/backend/src/main/java/org/example/backend/domain/notificationSetting/service/NotificationSettingService.java index d263b4fc..50658690 100644 --- a/backend/src/main/java/org/example/backend/domain/notificationSetting/service/NotificationSettingService.java +++ b/backend/src/main/java/org/example/backend/domain/notificationSetting/service/NotificationSettingService.java @@ -17,9 +17,9 @@ public class NotificationSettingService implements NotificationSettingServiceImp @Override public NotificationSettingResponseDTO getNotiSetting(UUID userId) { - NotificationSetting setting = notificationSettingRepository.findById(userId.toString()) + NotificationSetting setting = notificationSettingRepository.findById(userId) .orElseGet(() -> NotificationSetting.builder() - .userId(userId.toString()) + .userId(userId) .quizUpload(true) .quizAnswerUpload(true) .lectureNoteUpload(true) @@ -32,7 +32,7 @@ public NotificationSettingResponseDTO getNotiSetting(UUID userId) { @Transactional public void patchSettings(UUID userId, NotificationSettingPatchRequest req) { - NotificationSetting entity = notificationSettingRepository.findById(userId.toString()) + NotificationSetting entity = notificationSettingRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("알림 설정이 존재하지 않습니다.")); if (req.quizUpload() != null) entity.setQuizUpload(req.quizUpload()); @@ -41,4 +41,20 @@ public void patchSettings(UUID userId, NotificationSettingPatchRequest req) { if (req.lectureUpload() != null) entity.setLectureUpload(req.lectureUpload()); if (req.recordUpload() != null) entity.setRecordUpload(req.recordUpload()); } + + public void initializeDefaultSettings(UUID userId) { + // 이미 존재하는 설정이 있으면 중복 생성 방지 + if (notificationSettingRepository.existsByUserId(userId)) return; + + NotificationSetting notificationSetting = NotificationSetting.builder() + .userId(userId) + .quizUpload(true) + .quizAnswerUpload(true) + .lectureNoteUpload(true) + .lectureUpload(true) + .recordUpload(true) + .build(); + + notificationSettingRepository.save(notificationSetting); + } } diff --git a/backend/src/main/java/org/example/backend/domain/notificationSetting/service/NotificationSettingServiceImpl.java b/backend/src/main/java/org/example/backend/domain/notificationSetting/service/NotificationSettingServiceImpl.java index 1f5923a4..50c80ba5 100644 --- a/backend/src/main/java/org/example/backend/domain/notificationSetting/service/NotificationSettingServiceImpl.java +++ b/backend/src/main/java/org/example/backend/domain/notificationSetting/service/NotificationSettingServiceImpl.java @@ -9,4 +9,5 @@ public interface NotificationSettingServiceImpl { NotificationSettingResponseDTO getNotiSetting(UUID userId); void patchSettings(UUID userId, NotificationSettingPatchRequest req); + void initializeDefaultSettings(UUID userId); } diff --git a/backend/src/main/java/org/example/backend/domain/quiz/service/QuizServiceImpl.java b/backend/src/main/java/org/example/backend/domain/quiz/service/QuizServiceImpl.java index 9c061d14..12b3dd8a 100644 --- a/backend/src/main/java/org/example/backend/domain/quiz/service/QuizServiceImpl.java +++ b/backend/src/main/java/org/example/backend/domain/quiz/service/QuizServiceImpl.java @@ -181,6 +181,12 @@ public QuizSaveResponseDTO saveQuiz(UUID lectureId, QuizSaveRequestDTO request) } } } + notificationService.sendAlarmToAllStudentsInLecture( + lecture.getId(), + AlarmType.quizUpload, + "시스템", + lecture.getLectureName() + " 퀴즈가 게시되었습니다. 풀어보러 갈까요?." + ); scheduleQuizAnswerUploadNotification(lecture); @@ -206,6 +212,13 @@ private void scheduleQuizAnswerUploadNotification(Lecture lecture) { "시스템", lecture.getLectureName() + " 퀴즈 대시보드가 업로드 되었습니다." ); + + notificationService.sendAlarmToAllStudentsInLecture( + lecture.getId(), + AlarmType.quizAnswerUpload, + "시스템", + lecture.getLectureName() + " 퀴즈 정답이 공개되었습니다." + ); }, triggerTime); } diff --git a/backend/src/main/java/org/example/backend/domain/studentClass/repository/StudentClassRepository.java b/backend/src/main/java/org/example/backend/domain/studentClass/repository/StudentClassRepository.java index a01c9a6a..63a920aa 100644 --- a/backend/src/main/java/org/example/backend/domain/studentClass/repository/StudentClassRepository.java +++ b/backend/src/main/java/org/example/backend/domain/studentClass/repository/StudentClassRepository.java @@ -34,4 +34,11 @@ SELECT CASE WHEN COUNT(sc) > 0 THEN TRUE ELSE FALSE END ) """) boolean existsByUserIdAndLectureId(@Param("userId") UUID userId, @Param("lectureId") UUID lectureId); + + // 해당 클래스에 속한 학생들의 userId만 조회 + @Query("select sc.userId from StudentClass sc where sc.classId = :classId") + List findUserIdsByClassId(UUID classId); + + // 특정 학생이 해당 클래스에 속해있는지 여부 + boolean existsByClassIdAndUserId(UUID classId, UUID userId); } \ No newline at end of file diff --git a/backend/src/main/java/org/example/backend/global/userdeviceToken/controller/UserDeviceTokenController.java b/backend/src/main/java/org/example/backend/global/userdeviceToken/controller/UserDeviceTokenController.java index 724a1ae4..c99b0334 100644 --- a/backend/src/main/java/org/example/backend/global/userdeviceToken/controller/UserDeviceTokenController.java +++ b/backend/src/main/java/org/example/backend/global/userdeviceToken/controller/UserDeviceTokenController.java @@ -3,6 +3,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; +import org.example.backend.domain.notificationSetting.service.NotificationSettingService; import org.example.backend.global.security.auth.CustomSecurityUtil; import org.example.backend.global.security.auth.CustomUserDetails; import org.example.backend.global.userdeviceToken.dto.request.TokenRegisterRequest; @@ -23,12 +24,14 @@ public class UserDeviceTokenController { private final UserDeviceTokenService tokenService; private final CustomSecurityUtil customSecurityUtil; + private final NotificationSettingService notificationSettingService; @PostMapping("/register") public ResponseEntity registerToken(@RequestBody TokenRegisterRequest dto, @AuthenticationPrincipal CustomUserDetails userDetails) { UUID userId = customSecurityUtil.getUserId(); tokenService.registerToken(userId, dto.getToken()); + notificationSettingService.initializeDefaultSettings(userId); return ResponseEntity.ok("Token registered successfully"); } } \ No newline at end of file diff --git a/frontend/app/student/notification/page.module.scss b/frontend/app/student/notification/page.module.scss index e69de29b..3c8e708d 100644 --- a/frontend/app/student/notification/page.module.scss +++ b/frontend/app/student/notification/page.module.scss @@ -0,0 +1,74 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + min-height: 100vh; + background-color: #f8f9fa; + width: 100%; + box-sizing: border-box; +} + +.header { + position: relative; + width: 100%; + max-width: 800px; + text-align: center; + font-size: 18px; + font-weight: bold; + color: #333; + padding: 40px 0; + height: auto; +} + +.divider { + width: 100%; + max-width: 800px; + border: 0; + border-top: 1px dashed #ccc; + margin-bottom: 15px; +} + +.notificationList { + list-style: none; + padding: 0; + margin: 0; + width: 100%; + max-width: 800px; +} + +.notificationItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px 20px; + border-bottom: 1px solid #f0f0f0; + + &:last-child { + border-bottom: none; + } +} + +.title { + display: flex; + justify-content: space-between; + align-items: center; + flex-grow: 1; /* 남은 공간을 모두 차지 */ + font-size: 16px; + color: #555; + padding-right: 20px; /* newIndicator와의 간격 */ +} + +.dateTime { + font-size: 12px; + color: #999; + white-space: nowrap; + margin-left: 10px; +} + +.newIndicator { + width: 5px; + height: 5px; + background-color: #e53e3e; + border-radius: 50%; + flex-shrink: 0; +} \ No newline at end of file diff --git a/frontend/app/student/notification/page.tsx b/frontend/app/student/notification/page.tsx index 76499a1c..b59a60aa 100644 --- a/frontend/app/student/notification/page.tsx +++ b/frontend/app/student/notification/page.tsx @@ -1,3 +1,55 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import styles from "./page.module.scss"; +import { fetchNotifications , NotificationResponse, getAlarmMessage } from "@/api/notifications/fetchNotification"; + export default function StudentNotificationPage() { - return
알림
; -} + const [notifications, setNotifications] = useState([]); + + useEffect(() => { + const loadNotifications = async () => { + const res = await fetchNotifications(); + if (res.isSuccess && res.result) { + setNotifications(res.result); + } else { + console.error("알림 조회 실패:", res.message); + } + }; + loadNotifications(); + }, []); + + return ( +
+ +
    + {notifications.map((notification) => { + const date = new Date(notification.createdAt); + const formattedDate = date.toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const formattedTime = date.toLocaleTimeString("ko-KR", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + return ( +
  • +
    + + [{notification.className ?? "알 수 없음"}] {getAlarmMessage(notification.alarmType)} + + + {formattedDate} {formattedTime} + +
    +
  • + ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/student/profile/(profile)/_components/MenuSection.tsx b/frontend/app/student/profile/(profile)/_components/MenuSection.tsx index 72e87946..878aa3b1 100644 --- a/frontend/app/student/profile/(profile)/_components/MenuSection.tsx +++ b/frontend/app/student/profile/(profile)/_components/MenuSection.tsx @@ -35,8 +35,7 @@ export default function MenuSection() {
  • - 알림 설정 - {/* TODO: 추후 실제 알림설정 경로명으로 수정 */} + 알림 설정