diff --git a/momogo-api/src/main/java/com/momogo/api/notification/service/NotificationSseServiceImpl.java b/momogo-api/src/main/java/com/momogo/api/notification/service/NotificationSseServiceImpl.java index 219d0ef..9c0ee64 100644 --- a/momogo-api/src/main/java/com/momogo/api/notification/service/NotificationSseServiceImpl.java +++ b/momogo-api/src/main/java/com/momogo/api/notification/service/NotificationSseServiceImpl.java @@ -46,8 +46,10 @@ public SseEmitter connect(UUID userId) { // 연결 직후 더미 이벤트를 하나 보내는 이유: // 클라이언트가 아무 데이터도 안 보내고 가만히 있으면 연결이 대기상태로 오해받아 타임아웃/503이 날 수 있음 + // id를 함께 실어 보내는 이유: 클라이언트가 이 이벤트를 "연결이 실제로 확립된 시점"의 기준점으로 삼아, + // 그 이후에 초기 목록(GET)을 조회하도록 순서를 맞추기 위함 (목록 조회~SSE 연결 사이 알림 유실 방지) try { - emitter.send(SseEmitter.event().name("connect").data("connected")); + emitter.send(SseEmitter.event().name("connect").id(UUID.randomUUID().toString()).data("connected")); } catch (IOException e) { // 이 시점 실패는 서블릿 컨테이너의 오류 디스패치로 정리되므로 로그만 남김 log.warn("SSE 초기 연결 이벤트 전송 실패 - userId: {}", userId, e); diff --git a/momogo-api/src/main/java/com/momogo/api/superadmin/SuperAdminController.java b/momogo-api/src/main/java/com/momogo/api/superadmin/SuperAdminController.java index 33378ec..7f5747a 100644 --- a/momogo-api/src/main/java/com/momogo/api/superadmin/SuperAdminController.java +++ b/momogo-api/src/main/java/com/momogo/api/superadmin/SuperAdminController.java @@ -2,9 +2,16 @@ import com.momogo.core.domain.problem.dto.request.CategoryCreateRequest; import com.momogo.core.domain.problem.dto.request.CategoryUpdateRequest; +import com.momogo.core.domain.problem.dto.request.ProblemUpdateRequest; import com.momogo.core.domain.problem.dto.response.CategoryResponse; +import com.momogo.core.domain.problem.dto.response.ProblemDetailResponse; +import com.momogo.core.domain.problem.dto.response.ProblemResponse; import com.momogo.core.domain.problem.service.ProblemCategoryService; +import com.momogo.core.domain.problem.service.ProblemService; +import com.momogo.core.domain.space.dto.request.SpaceUpdateRequest; import com.momogo.core.domain.space.dto.response.SpaceResponse; +import com.momogo.core.domain.space.entity.Space; +import com.momogo.core.domain.space.mapper.SpaceMapper; import com.momogo.core.domain.space.service.SpaceService; import com.momogo.core.domain.user.dto.request.UserBannedRequest; import com.momogo.core.domain.user.dto.request.UserPageRequest; @@ -20,11 +27,13 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -36,8 +45,10 @@ public class SuperAdminController { private final SpaceService spaceService; + private final SpaceMapper spaceMapper; private final UserService userService; private final ProblemCategoryService problemCategoryService; + private final ProblemService problemService; /** * 전체 공간 조회 @@ -51,6 +62,67 @@ public ResponseEntity> getAllSpaces( return ResponseEntity.ok(spaceService.getAllSpaces(pageable)); } + /** + * 공간 강제 수정 (SUPER_ADMIN 전용) + * @param spaceId 공간 ID + * @param request 공간 수정 요청 DTO + * @return 수정된 공간 정보 + */ + @PutMapping("/spaces/{spaceId}") + public ResponseEntity forceUpdateSpace( + @PathVariable UUID spaceId, + @Valid @RequestBody SpaceUpdateRequest request + ) { + Space space = spaceService.forceUpdateSpace(spaceId, request); + return ResponseEntity.ok(spaceMapper.toResponse(space)); + } + + /** + * 공간 강제 폐쇄 (SUPER_ADMIN 전용) + * @param spaceId 공간 ID + */ + @DeleteMapping("/spaces/{spaceId}") + public ResponseEntity forceDeleteSpace(@PathVariable UUID spaceId) { + spaceService.forceDeleteSpace(spaceId); + return ResponseEntity.noContent().build(); + } + + /** + * 전체 문제 조회 (SUPER_ADMIN 전용, 공간 무관, 정답 포함) + * @param pageable 페이징 정보 + * @return 전체 문제 정보 + */ + @GetMapping("/problems") + public ResponseEntity> getAllProblems( + @PageableDefault(size = 10) Pageable pageable + ) { + return ResponseEntity.ok(problemService.getAllProblems(pageable)); + } + + /** + * 문제 강제 수정 (SUPER_ADMIN 전용) + * @param problemId 문제 ID + * @param request 문제 수정 요청 DTO + * @return 수정된 문제 정보 + */ + @PutMapping("/problems/{problemId}") + public ResponseEntity forceUpdateProblem( + @PathVariable UUID problemId, + @Valid @RequestBody ProblemUpdateRequest request + ) { + return ResponseEntity.ok(problemService.updateProblemAsSuperAdmin(problemId, request)); + } + + /** + * 문제 강제 삭제 (SUPER_ADMIN 전용) + * @param problemId 문제 ID + */ + @DeleteMapping("/problems/{problemId}") + public ResponseEntity forceDeleteProblem(@PathVariable UUID problemId) { + problemService.deleteProblemAsSuperAdmin(problemId); + return ResponseEntity.noContent().build(); + } + /** * 전체 유저 목록을 페이징(커서 기반) 조회합니다. (SUPER_ADMIN 전용) diff --git a/momogo-core/src/main/java/com/momogo/core/domain/problem/repository/ProblemRepository.java b/momogo-core/src/main/java/com/momogo/core/domain/problem/repository/ProblemRepository.java index d60dee9..e604cae 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/problem/repository/ProblemRepository.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/problem/repository/ProblemRepository.java @@ -3,6 +3,8 @@ import com.momogo.core.domain.problem.entity.Problem; import java.util.Optional; import java.util.UUID; +import org.springframework.data.domain.Page; +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; @@ -19,6 +21,16 @@ public interface ProblemRepository extends JpaRepository, Problem @Query("SELECT p FROM Problem p JOIN FETCH p.category JOIN FETCH p.space WHERE p.id = :id") Optional findByIdWithCategory(@Param("id") UUID id); + /** + * 전체 문제 목록 조회 (SUPER_ADMIN 전용, 공간 무관) + * - 카테고리/공간을 JOIN FETCH 하여 N+1 방지 + */ + @Query( + value = "SELECT p FROM Problem p JOIN FETCH p.category JOIN FETCH p.space", + countQuery = "SELECT count(p) FROM Problem p" + ) + Page findAllWithCategory(Pageable pageable); + /** * 카테고리 삭제 전 확인 메서드 * - 카테고리 삭제 전 관련되어 있는 문제가 남아 있으면 diff --git a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemService.java b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemService.java index 24568b0..80a760c 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemService.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemService.java @@ -11,6 +11,8 @@ import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface ProblemService { @@ -77,4 +79,23 @@ ProblemCursorResponse getProblems( * @param request AI 문제 자동 생성 요청 DTO */ List createProblemsByAi(UUID spaceId, UUID idempotencyKey, ProblemAiCreateRequest request); + + /** + * 전체 문제 목록 조회 (SUPER_ADMIN 전용, 공간 무관, 정답 포함) + * @param pageable 페이징 정보 + */ + Page getAllProblems(Pageable pageable); + + /** + * 문제 강제 수정 (SUPER_ADMIN 전용, 공간 소속 ADMIN 검증 없이 임의 문제 수정) + * @param problemId 문제 ID + * @param request 문제 수정 DTO + */ + ProblemResponse updateProblemAsSuperAdmin(UUID problemId, ProblemUpdateRequest request); + + /** + * 문제 강제 삭제 (SUPER_ADMIN 전용, 공간 소속 ADMIN 검증 없이 임의 문제 삭제) + * @param problemId 문제 ID + */ + void deleteProblemAsSuperAdmin(UUID problemId); } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemServiceImpl.java index c15dd7b..9ab1eff 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/problem/service/ProblemServiceImpl.java @@ -31,6 +31,8 @@ import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Service; @@ -347,4 +349,38 @@ public List createProblemsByAi(UUID spaceId, UUID idempotencyKe redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), ownerToken); } } + + /** + * 전체 문제 목록 조회 (SUPER_ADMIN 전용, 공간 무관, 정답 포함) + */ + @Override + public Page getAllProblems(Pageable pageable) { + return problemRepository.findAllWithCategory(pageable).map(problemMapper::toDetailResponse); + } + + /** + * 문제 강제 수정 (SUPER_ADMIN 전용) + * - 문제가 소속된 공간을 조회한 뒤 기존 updateProblem 검증/수정 로직에 위임한다. + */ + @Override + @Transactional + public ProblemResponse updateProblemAsSuperAdmin(UUID problemId, ProblemUpdateRequest request) { + Problem problem = problemRepository.findById(problemId) + .orElseThrow(() -> new BusinessException(ProblemErrorCode.PROBLEM_NOT_FOUND)); + + return updateProblem(problem.getSpace().getId(), problemId, request); + } + + /** + * 문제 강제 삭제 (SUPER_ADMIN 전용) + * - 문제가 소속된 공간을 조회한 뒤 기존 deleteProblem 검증/삭제 로직에 위임한다. + */ + @Override + @Transactional + public void deleteProblemAsSuperAdmin(UUID problemId) { + Problem problem = problemRepository.findById(problemId) + .orElseThrow(() -> new BusinessException(ProblemErrorCode.PROBLEM_NOT_FOUND)); + + deleteProblem(problem.getSpace().getId(), problemId); + } } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceService.java b/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceService.java index 4cd21ae..195134e 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceService.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceService.java @@ -37,4 +37,10 @@ public interface SpaceService { // 전체 공간 조회 (SUPER_ADMIN 권한 검증) Page getAllSpaces(Pageable pageable); + + // 공간 강제 수정 (SUPER_ADMIN 전용, 소유 ADMIN 검증 없이 임의 공간 수정) + Space forceUpdateSpace(UUID spaceId, SpaceUpdateRequest request); + + // 공간 강제 폐쇄 (SUPER_ADMIN 전용, 소유 ADMIN 검증 없이 임의 공간 삭제) + void forceDeleteSpace(UUID spaceId); } diff --git a/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceServiceImpl.java b/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceServiceImpl.java index 82db64f..167e350 100644 --- a/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceServiceImpl.java +++ b/momogo-core/src/main/java/com/momogo/core/domain/space/service/SpaceServiceImpl.java @@ -200,6 +200,35 @@ public Page getAllSpaces(Pageable pageable) { return spaceRepository.findAll(pageable).map(spaceMapper::toResponse); } + @Override + @Transactional + public Space forceUpdateSpace(UUID spaceId, SpaceUpdateRequest request) { + + Space space = spaceRepository.findById(spaceId) + .orElseThrow(() -> new BusinessException(SpaceErrorCode.SPACE_NOT_FOUND)); + + spaceMapper.updateFromDto(request, space); // MapStruct 더티 체킹 + + if (request.spacePassword() != null && !request.spacePassword().isBlank()) { + space.updateSpacePassword(passwordEncryptor.encrypt(request.spacePassword())); + } + + return space; + } + + @Override + @Transactional + public void forceDeleteSpace(UUID spaceId) { + + Space space = spaceRepository.findById(spaceId) + .orElseThrow(() -> new BusinessException(SpaceErrorCode.SPACE_NOT_FOUND)); + + // 벌크 쿼리로 공간 소속 유저 탈퇴 처리 + userRepository.bulkLeaveSpace(spaceId, UserRole.USER); + + spaceRepository.delete(space); + } + // 공통 헬퍼 메소드: 사용자 조회 및 공간 관리자 권한 검증 private User validateAndGetSpaceAdmin(UUID userId, UUID spaceId) { User user = userRepository.findById(userId) diff --git a/momogo-frontend/src/pages/DashboardPage.tsx b/momogo-frontend/src/pages/DashboardPage.tsx index bc73c7a..43f2e09 100644 --- a/momogo-frontend/src/pages/DashboardPage.tsx +++ b/momogo-frontend/src/pages/DashboardPage.tsx @@ -249,10 +249,27 @@ export const DashboardPage: React.FC = ({ user, initialTab, setShowConfirmModal(true); }; + // 백엔드가 Page 응답(기본 size=10)을 주기 때문에, 관리자 콘솔의 "전체 목록" 요건을 맞추려면 + // last=true가 될 때까지 모든 페이지를 순회하여 합쳐야 한다. + const loadAllPages = async (path: string): Promise => { + let page = 0; + let all: any[] = []; + while (true) { + const data = await request<{ content: any[]; last: boolean }>(path, { + method: 'GET', + params: { page: String(page), size: '100' } + }); + if (!data) break; + all = all.concat(data.content); + if (data.last) break; + page += 1; + } + return all; + }; + const loadSuperAdminSpaces = async () => { try { - const data = await request('/api/super-admin/spaces', { method: 'GET' }); - if (data) setSuperSpaces(data); + setSuperSpaces(await loadAllPages('/api/super-admin/spaces')); } catch (err: any) { showToast(err.message || '공간 목록 로드 실패', 'error'); } @@ -260,8 +277,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, const loadSuperAdminProblems = async () => { try { - const data = await request('/api/super-admin/problems', { method: 'GET' }); - if (data) setSuperProblems(data); + setSuperProblems(await loadAllPages('/api/super-admin/problems')); } catch (err: any) { showToast(err.message || '문제 목록 로드 실패', 'error'); } @@ -437,9 +453,24 @@ export const DashboardPage: React.FC = ({ user, initialTab, loadInitialData(); }, []); - // 알림 실시간 수신(SSE) 연결 - 신규 알림 발생 시 목록 맨 앞에 추가 + // 알림 실시간 수신(SSE) 연결 + // SSE 연결이 확립된 뒤에 초기 목록(GET)을 조회해야, "목록 조회~SSE 연결" 사이에 발생한 + // 알림을 놓치지 않는다. 재연결 시에도 다시 확립 시점 기준으로 목록을 새로 맞춘다. useEffect(() => { + const loadNotifications = async () => { + try { + const notiData = await request('/api/notifications', { method: 'GET' }); + // GET은 확인 완료된 알림까지 전부 내려주므로, 미확인 알림만 목록에 남긴다. + setNotifications((notiData?.data || []).filter((n: NotificationItem) => !n.isConfirmed)); + } catch (err) { + console.error('Failed to load notifications', err); + } + }; + const disconnect = connectNotificationSse({ + onConnect: () => { + loadNotifications(); + }, onNotification: (noti: NotificationItem) => { setNotifications(prev => (prev.some(n => n.id === noti.id) ? prev : [noti, ...prev])); }, @@ -465,21 +496,13 @@ export const DashboardPage: React.FC = ({ user, initialTab, console.error('대시보드 요약 조회 실패', err); } - // 2. 알림 조회 - try { - const notiData = await request('/api/notifications', { method: 'GET' }); - setNotifications(notiData?.data || []); - } catch (err) { - console.error('Failed to load notifications', err); - } - - // 3. 미가입 공간 조회 + // 2. 미가입 공간 조회 const unjoinedData = await request<{ values: SpaceResponse[] }>('/api/spaces/unjoined', { method: 'GET' }); if (unjoinedData && unjoinedData.values) { setUnjoinedSpaces(unjoinedData.values); } - // 4. 슈퍼관리자 권한인 경우 관련 데이터 미리 조회 + // 3. 슈퍼관리자 권한인 경우 관련 데이터 미리 조회 if (user.role === 'SUPER_ADMIN') { await loadSuperAdminUsers(null, null); await loadSuperAdminCategories(); @@ -840,6 +863,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, setNotifications(prev => prev.filter(n => n.id !== id)); } catch (err: any) { console.error('알림 읽음 처리 실패:', err.message); + showToast(err.message || '알림 확인 처리에 실패했습니다.', 'error'); } }; @@ -1639,7 +1663,6 @@ export const DashboardPage: React.FC = ({ user, initialTab,

서비스 개설 공간 전체 목록 - 데모 데이터 (백엔드 미연동)

@@ -1697,7 +1720,6 @@ export const DashboardPage: React.FC = ({ user, initialTab,

전체 출제 문제 목록 - 데모 데이터 (백엔드 미연동)

@@ -1715,7 +1737,7 @@ export const DashboardPage: React.FC = ({ user, initialTab, @@ -2628,17 +2650,6 @@ const styles: Record = { fontFamily: "'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif", letterSpacing: '-0.025em', }, - demoBadge: { - marginLeft: '0.5rem', - padding: '0.15rem 0.5rem', - borderRadius: '999px', - fontSize: '0.7rem', - fontWeight: 700, - color: '#b45309', - backgroundColor: '#fef3c7', - border: '1px solid #fde68a', - verticalAlign: 'middle', - }, tableWrapper: { width: '100%', overflowX: 'auto', diff --git a/momogo-frontend/src/pages/SpacePage.tsx b/momogo-frontend/src/pages/SpacePage.tsx index 8dd2acd..9ed286f 100644 --- a/momogo-frontend/src/pages/SpacePage.tsx +++ b/momogo-frontend/src/pages/SpacePage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useRef } from 'react'; import type { UserResponse } from '../types/user'; import type { SpaceResponse } from '../types/space'; -import { request, getAccessToken } from '../services/api'; +import { request, getAccessToken, connectNotificationSse } from '../services/api'; interface SpacePageProps { user: UserResponse; @@ -17,6 +17,15 @@ interface CategoryResponse { name: string; } +interface NotificationItem { + id: string; + title: string; + content: string; + type: string; + isConfirmed: boolean; + createdAt: string; +} + interface ProblemResponse { id: string; name: string; @@ -231,6 +240,8 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT const [allUsersList, setAllUsersList] = useState([]); const [showUserMenu, setShowUserMenu] = useState(false); + const [notifications, setNotifications] = useState([]); + const [showNotifications, setShowNotifications] = useState(false); // 2-1. 실시간 대기실 / 시험 모드 관련 상태 const [currentExamRoom, setCurrentExamRoom] = useState(null); @@ -268,6 +279,47 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT loadMembersForInvitation(); }, [selectedCategory, activeTab]); + // 알림 실시간 수신(SSE) 연결 + // SSE 연결이 확립된 뒤에 초기 목록(GET)을 조회해야, "목록 조회~SSE 연결" 사이에 발생한 + // 알림을 놓치지 않는다. 재연결 시에도 다시 확립 시점 기준으로 목록을 새로 맞춘다. + useEffect(() => { + const loadNotifications = async () => { + try { + const notiData = await request('/api/notifications', { method: 'GET' }); + // GET은 확인 완료된 알림까지 전부 내려주므로, 미확인 알림만 목록에 남긴다. + setNotifications((notiData?.data || []).filter((n: NotificationItem) => !n.isConfirmed)); + } catch (err) { + console.error('Failed to load notifications', err); + } + }; + + const disconnect = connectNotificationSse({ + onConnect: () => { + loadNotifications(); + }, + onNotification: (noti: NotificationItem) => { + setNotifications(prev => (prev.some(n => n.id === noti.id) ? prev : [noti, ...prev])); + }, + onError: (err) => { + console.error('알림 SSE 연결 오류:', err); + }, + }); + return () => disconnect(); + }, []); + + // 알림 개별 읽음 처리 + const handleConfirmNotification = async (id: string) => { + try { + await request(`/api/notifications/${id}/confirm`, { + method: 'PATCH', + }); + setNotifications(prev => prev.filter(n => n.id !== id)); + } catch (err: any) { + console.error('알림 읽음 처리 실패:', err.message); + showToast(err.message || '알림 확인 처리에 실패했습니다.', 'error'); + } + }; + // 대기실 또는 시험 중일 때의 타이머 처리 useEffect(() => { if (examMode === 'testing' && timeLeft > 0) { @@ -984,6 +1036,8 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT return `${hours}시간 ${minutes}분`; })(); + const unreadNotiCount = notifications.filter(n => !n.isConfirmed).length; + return (
{/* 사이드바 메뉴 */} @@ -1039,8 +1093,17 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT

{space.description}

+ +
-
= ({ user, space, onBack, showT cursor: 'pointer', boxShadow: '0 1px 3px rgba(16, 24, 40, 0.05)', }} - onClick={() => setShowUserMenu(!showUserMenu)} + onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }} > = ({ user, space, onBack, showT
+ {/* 실시간 알림 팝오버 */} + {showNotifications && ( +
+

수신된 최근 알림

+ {notifications.length === 0 ? ( +

수신된 새 알림이 없습니다.

+ ) : ( +
+ {notifications.map(noti => ( +
+

{noti.content}

+ +
+ ))} +
+ )} +
+ )} + {/* 싱글 문제은행 탭 */} {activeTab === 'problems' && (
@@ -2449,6 +2537,79 @@ export const SpacePage: React.FC = ({ user, space, onBack, showT }; const styles: Record = { + notiTriggerBtn: { + position: 'relative', + paddingRight: '2rem', + }, + notiCountBadge: { + position: 'absolute', + top: '50%', + right: '0.5rem', + transform: 'translateY(-50%)', + backgroundColor: '#ef4444', + color: '#ffffff', + fontSize: '0.7rem', + fontWeight: 700, + borderRadius: '50%', + width: '18px', + height: '18px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + notiPopover: { + position: 'absolute', + top: '5rem', + right: '3rem', + width: '320px', + zIndex: 100, + padding: '1rem', + backgroundColor: '#ffffff', + borderRadius: '12px', + boxShadow: '0 10px 15px -3px rgba(16, 24, 40, 0.08)', + }, + notiPopoverTitle: { + fontSize: '0.875rem', + fontWeight: 700, + color: '#1d2939', + marginBottom: '0.75rem', + borderBottom: '1px solid #eaecf0', + paddingBottom: '0.5rem', + }, + notiEmpty: { + fontSize: '0.825rem', + color: '#98a2b3', + textAlign: 'center', + padding: '1rem 0', + }, + notiList: { + display: 'flex', + flexDirection: 'column', + gap: '0.5rem', + maxHeight: '200px', + overflowY: 'auto', + }, + notiItem: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: '0.5rem', + padding: '0.5rem 0', + borderBottom: '1px solid #f2f4f7', + }, + notiText: { + fontSize: '0.775rem', + color: '#475467', + flex: 1, + lineHeight: 1.4, + }, + notiConfirmBtn: { + fontSize: '0.7rem', + padding: '0.25rem 0.5rem', + backgroundColor: '#f2f4f7', + border: 'none', + color: '#475467', + }, sidebarTitle: { fontSize: '1.75rem', fontWeight: 800, diff --git a/momogo-frontend/src/services/api.ts b/momogo-frontend/src/services/api.ts index dd570f7..1c815e6 100644 --- a/momogo-frontend/src/services/api.ts +++ b/momogo-frontend/src/services/api.ts @@ -75,72 +75,6 @@ export async function request(path: string, options: RequestOptions = {}): Pr let resolvedPath = path; - // 모의 응답 인터셉터 (Mock Interceptor) - const lowerPath = resolvedPath.toLowerCase(); - - // C. 슈퍼 관리자용 모의 처리 (공간 및 문제 관리) - if (lowerPath.startsWith('/api/super-admin/spaces')) { - let superSpaces = JSON.parse(localStorage.getItem('momogo_super_spaces') || '[]'); - if (superSpaces.length === 0) { - superSpaces = [ - { id: 'space-1', name: '마포고 수학 문제은행', description: '마포고 학생들을 위한 수학 퀴즈방', profileImageUrl: null, createdAt: new Date().toISOString() }, - { id: 'space-2', name: '수능 영어 1등급 정복', description: 'EBS 연계 교재 및 기출문제 모음', profileImageUrl: null, createdAt: new Date().toISOString() } - ]; - localStorage.setItem('momogo_super_spaces', JSON.stringify(superSpaces)); - } - - const method = options.method?.toUpperCase() || 'GET'; - if (method === 'GET') { - return superSpaces as unknown as T; - } - - if (method === 'PUT' || method === 'PATCH') { - const spaceId = resolvedPath.split('/').pop(); - const body = JSON.parse(options.body as string); - superSpaces = superSpaces.map((s: any) => s.id === spaceId ? { ...s, ...body } : s); - localStorage.setItem('momogo_super_spaces', JSON.stringify(superSpaces)); - return { id: spaceId, ...body } as unknown as T; - } - - if (method === 'DELETE') { - const spaceId = resolvedPath.split('/').pop(); - superSpaces = superSpaces.filter((s: any) => s.id !== spaceId); - localStorage.setItem('momogo_super_spaces', JSON.stringify(superSpaces)); - return null as unknown as T; - } - } - - if (lowerPath.startsWith('/api/super-admin/problems')) { - let superProblems = JSON.parse(localStorage.getItem('momogo_super_problems') || '[]'); - if (superProblems.length === 0) { - superProblems = [ - { id: 'prob-1', name: '미적분 기초 계산', content: 'f(x) = x^2 일 때 f\'(3)의 값은?', correctAnswer: '6', explanation: '도함수는 2x이므로 3을 대입하면 6입니다.', category: { id: 'cat-1', name: '수학' } }, - { id: 'prob-2', name: '영어 빈칸 추론', content: '다음 빈칸에 가장 알맞은 단어는? "Actions speak louder than _______."', correctAnswer: 'words', explanation: '말보다 행동이 중요하다는 뜻의 속담입니다.', category: { id: 'cat-2', name: '영어' } } - ]; - localStorage.setItem('momogo_super_problems', JSON.stringify(superProblems)); - } - - const method = options.method?.toUpperCase() || 'GET'; - if (method === 'GET') { - return superProblems as unknown as T; - } - - if (method === 'PUT' || method === 'PATCH') { - const problemId = resolvedPath.split('/').pop(); - const body = JSON.parse(options.body as string); - superProblems = superProblems.map((p: any) => p.id === problemId ? { ...p, ...body } : p); - localStorage.setItem('momogo_super_problems', JSON.stringify(superProblems)); - return { id: problemId, ...body } as unknown as T; - } - - if (method === 'DELETE') { - const problemId = resolvedPath.split('/').pop(); - superProblems = superProblems.filter((p: any) => p.id !== problemId); - localStorage.setItem('momogo_super_problems', JSON.stringify(superProblems)); - return null as unknown as T; - } - } - // URL 파라미터 조립 (ISO-8601 커서 등 + 기호가 포함된 파라미터의 %2B 인코딩 보장) let url = resolvedPath; if (params) { @@ -234,6 +168,10 @@ export async function request(path: string, options: RequestOptions = {}): Pr export interface NotificationSseHandlers { onNotification: (data: any) => void; + // SSE 연결이 실제로 확립된 시점(서버의 최초 connect 이벤트 수신)에 호출된다. + // 이 콜백을 받은 뒤에 초기 목록(GET)을 조회해야, "목록 조회~SSE 연결" 사이에 발생한 + // 알림을 놓치지 않는다. eventId는 서버가 connect 이벤트에 실어 보낸 SSE id 필드 값. + onConnect?: (eventId: string | null) => void; onError?: (err: unknown) => void; } @@ -289,13 +227,16 @@ export function connectNotificationSse(handlers: NotificationSseHandlers): () => buffer = buffer.slice(sepIndex + 2); const eventName = /^event:\s*(.+)$/m.exec(rawEvent)?.[1]?.trim() || 'message'; + const eventId = /^id:\s*(.+)$/m.exec(rawEvent)?.[1]?.trim() ?? null; const dataStr = rawEvent .split('\n') .filter(line => line.startsWith('data:')) .map(line => line.slice(5).trim()) .join('\n'); - if (eventName === 'notifications' && dataStr) { + if (eventName === 'connect') { + handlers.onConnect?.(eventId); + } else if (eventName === 'notifications' && dataStr) { try { handlers.onNotification(JSON.parse(dataStr)); } catch (e) {
{p.name} - {p.category?.name || '미분류'} + {p.categoryName || '미분류'} {p.correctAnswer} {p.content}