diff --git a/apps/native/src/apis/controller/student/scrap/handwriting/putUpdateHandwriting.ts b/apps/native/src/apis/controller/student/scrap/handwriting/putUpdateHandwriting.ts index ba36ca929..b66a621e3 100644 --- a/apps/native/src/apis/controller/student/scrap/handwriting/putUpdateHandwriting.ts +++ b/apps/native/src/apis/controller/student/scrap/handwriting/putUpdateHandwriting.ts @@ -1,7 +1,7 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { type paths } from '@schema'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; type UpdateHandwritingRequest = paths['/api/student/scrap/{scrapId}/handwriting']['put']['requestBody']['content']['application/json']; @@ -14,28 +14,21 @@ interface UpdateHandwritingParams { } export const useUpdateHandwriting = () => { - const queryClient = useQueryClient(); - return useMutation({ mutationFn: async ({ scrapId, request, }: UpdateHandwritingParams): Promise => { - const { data } = await client.PUT('/api/student/scrap/{scrapId}/handwriting', { + const { data, response } = await client.PUT('/api/student/scrap/{scrapId}/handwriting', { params: { path: { scrapId }, }, body: request, }); + if (!response.ok) { + throw new Error(`handwriting PUT failed: ${response.status}`); + } return data as UpdateHandwritingResponse; }, - onSuccess: (response, { scrapId }) => { - queryClient.setQueryData( - TanstackQueryClient.queryOptions('get', '/api/student/scrap/{scrapId}/handwriting', { - params: { path: { scrapId } }, - }).queryKey, - response - ); - }, }); }; diff --git a/apps/native/src/features/student/scrap/hooks/__tests__/handwritingFlushPending.test.ts b/apps/native/src/features/student/scrap/hooks/__tests__/handwritingFlushPending.test.ts new file mode 100644 index 000000000..06bfbf9d6 --- /dev/null +++ b/apps/native/src/features/student/scrap/hooks/__tests__/handwritingFlushPending.test.ts @@ -0,0 +1,165 @@ +import { + runExplicitFlushLoop, + type FlushPendingQueue, + type RunExplicitFlushLoopDeps, +} from '../handwritingFlushPending'; +import type { ExplicitFlushResult } from '../../services/handwritingSaveQueue'; + +const SCRAP_ID = 100; +const DATA = '{"strokes":[],"texts":[]}'; + +const makeQueue = ( + outcomes: ExplicitFlushResult['outcome'][] +): FlushPendingQueue & { + flushExplicit: jest.Mock; + dequeue: jest.Mock; +} => { + const queue = { + flushExplicit: jest.fn(), + dequeue: jest.fn(), + }; + outcomes.forEach((outcome, i) => { + queue.flushExplicit.mockResolvedValueOnce({ outcome, version: i + 1 }); + }); + return queue; +}; + +describe('runExplicitFlushLoop', () => { + it('success → return "success", alert 호출 0', async () => { + const queue = makeQueue(['success']); + const showRetryAlert = jest.fn(); + const onDiscard = jest.fn(); + + const result = await runExplicitFlushLoop({ + scrapId: SCRAP_ID, + dataJson: DATA, + queue, + showRetryAlert, + onDiscard, + }); + + expect(result).toBe('success'); + expect(queue.flushExplicit).toHaveBeenCalledTimes(1); + expect(queue.flushExplicit).toHaveBeenCalledWith(SCRAP_ID, DATA); + expect(showRetryAlert).not.toHaveBeenCalled(); + expect(queue.dequeue).not.toHaveBeenCalled(); + expect(onDiscard).not.toHaveBeenCalled(); + }); + + it('retry → 사용자 "다시 시도" → 두번째 success → return "success"', async () => { + const queue = makeQueue(['retry', 'success']); + const showRetryAlert = jest.fn().mockResolvedValueOnce('retry'); + const onDiscard = jest.fn(); + + const result = await runExplicitFlushLoop({ + scrapId: SCRAP_ID, + dataJson: DATA, + queue, + showRetryAlert, + onDiscard, + }); + + expect(result).toBe('success'); + expect(queue.flushExplicit).toHaveBeenCalledTimes(2); + expect(showRetryAlert).toHaveBeenCalledTimes(1); + expect(queue.dequeue).not.toHaveBeenCalled(); + expect(onDiscard).not.toHaveBeenCalled(); + }); + + it('retry → 사용자 "확인" (discard) → dequeue + onDiscard → return "discard"', async () => { + const queue = makeQueue(['retry']); + const showRetryAlert = jest.fn().mockResolvedValue('discard'); + const onDiscard = jest.fn(); + + const result = await runExplicitFlushLoop({ + scrapId: SCRAP_ID, + dataJson: DATA, + queue, + showRetryAlert, + onDiscard, + }); + + expect(result).toBe('discard'); + expect(queue.flushExplicit).toHaveBeenCalledTimes(1); + expect(showRetryAlert).toHaveBeenCalledTimes(1); + expect(queue.dequeue).toHaveBeenCalledWith(SCRAP_ID); + expect(onDiscard).toHaveBeenCalledTimes(1); + }); + + it('hold → 사용자 "다시 시도" → success → return "success"', async () => { + const queue = makeQueue(['hold', 'success']); + const showRetryAlert = jest.fn().mockResolvedValueOnce('retry'); + const onDiscard = jest.fn(); + + const result = await runExplicitFlushLoop({ + scrapId: SCRAP_ID, + dataJson: DATA, + queue, + showRetryAlert, + onDiscard, + }); + + expect(result).toBe('success'); + expect(showRetryAlert).toHaveBeenCalledTimes(1); + }); + + it('timeout → 사용자 "확인" → discard', async () => { + const queue = makeQueue(['timeout']); + const showRetryAlert = jest.fn().mockResolvedValue('discard'); + const onDiscard = jest.fn(); + + const result = await runExplicitFlushLoop({ + scrapId: SCRAP_ID, + dataJson: DATA, + queue, + showRetryAlert, + onDiscard, + }); + + expect(result).toBe('discard'); + expect(queue.dequeue).toHaveBeenCalledWith(SCRAP_ID); + expect(onDiscard).toHaveBeenCalledTimes(1); + }); + + it('연속 retry 3회 → 마지막 discard → dequeue + onDiscard', async () => { + const queue = makeQueue(['retry', 'retry', 'retry']); + const showRetryAlert = jest + .fn() + .mockResolvedValueOnce('retry') + .mockResolvedValueOnce('retry') + .mockResolvedValueOnce('discard'); + const onDiscard = jest.fn(); + + const result = await runExplicitFlushLoop({ + scrapId: SCRAP_ID, + dataJson: DATA, + queue, + showRetryAlert, + onDiscard, + }); + + expect(result).toBe('discard'); + expect(queue.flushExplicit).toHaveBeenCalledTimes(3); + expect(showRetryAlert).toHaveBeenCalledTimes(3); + expect(queue.dequeue).toHaveBeenCalledTimes(1); + expect(onDiscard).toHaveBeenCalledTimes(1); + }); + + it('discard 시 dequeue 가 onDiscard 보다 먼저 호출됨 (순서 보장)', async () => { + const queue = makeQueue(['retry']); + const calls: string[] = []; + queue.dequeue.mockImplementation(() => calls.push('dequeue')); + const showRetryAlert = jest.fn().mockResolvedValue('discard'); + const onDiscard = jest.fn().mockImplementation(() => calls.push('onDiscard')); + + await runExplicitFlushLoop({ + scrapId: SCRAP_ID, + dataJson: DATA, + queue, + showRetryAlert, + onDiscard, + } satisfies RunExplicitFlushLoopDeps); + + expect(calls).toEqual(['dequeue', 'onDiscard']); + }); +}); diff --git a/apps/native/src/features/student/scrap/hooks/handwritingFlushPending.ts b/apps/native/src/features/student/scrap/hooks/handwritingFlushPending.ts new file mode 100644 index 000000000..902719190 --- /dev/null +++ b/apps/native/src/features/student/scrap/hooks/handwritingFlushPending.ts @@ -0,0 +1,45 @@ +/** + * explicit flush 의 핵심 분기 로직을 hook 외부 pure 함수로 추출. + * + * 책임: + * - 큐 flushExplicit 호출 → outcome 분기 + * - success → return + * - 그 외 (retry/hold/timeout) → showRetryAlert 호출 → 사용자 결정에 따라 retry 또는 discard + * + * hook 외부 pure 함수라 jest 로 직접 단위 테스트 가능. + */ +import type { ExplicitFlushResult } from '../services/handwritingSaveQueue'; + +export interface FlushPendingQueue { + flushExplicit: (scrapId: number, data: string) => Promise; + dequeue: (scrapId: number) => void; +} + +export interface RunExplicitFlushLoopDeps { + scrapId: number; + dataJson: string; + queue: FlushPendingQueue; + /** 사용자에게 "재시도/확인" Alert 표시. 'retry' / 'discard' 반환. */ + showRetryAlert: () => Promise<'retry' | 'discard'>; + /** discard 결정 시 호출 (예: queryClient.removeQueries 등 cache cleanup). */ + onDiscard: () => void; +} + +/** + * @returns 'success' | 'discard' + */ +export async function runExplicitFlushLoop( + deps: RunExplicitFlushLoopDeps +): Promise<'success' | 'discard'> { + while (true) { + const result = await deps.queue.flushExplicit(deps.scrapId, deps.dataJson); + if (result.outcome === 'success') { + return 'success'; + } + const action = await deps.showRetryAlert(); + if (action === 'retry') continue; + deps.queue.dequeue(deps.scrapId); + deps.onDiscard(); + return 'discard'; + } +} diff --git a/apps/native/src/features/student/scrap/hooks/useDrawingState.ts b/apps/native/src/features/student/scrap/hooks/useDrawingState.ts index 97f1d8724..11d1ab2e7 100644 --- a/apps/native/src/features/student/scrap/hooks/useDrawingState.ts +++ b/apps/native/src/features/student/scrap/hooks/useDrawingState.ts @@ -8,7 +8,6 @@ export interface DrawingState { eraserSize: number; canUndo: boolean; canRedo: boolean; - hasUnsavedChanges: boolean; } type DrawingAction = @@ -16,8 +15,6 @@ type DrawingAction = | { type: 'SET_STROKE_WIDTH'; width: number } | { type: 'SET_ERASER_SIZE'; size: number } | { type: 'SET_HISTORY_STATE'; canUndo: boolean; canRedo: boolean } - | { type: 'SET_UNSAVED_CHANGES'; hasChanges: boolean } - | { type: 'MARK_AS_SAVED' } | { type: 'RESET' }; const initialState: DrawingState = { @@ -26,7 +23,6 @@ const initialState: DrawingState = { eraserSize: 22, canUndo: false, canRedo: false, - hasUnsavedChanges: false, }; function drawingReducer(state: DrawingState, action: DrawingAction): DrawingState { @@ -38,16 +34,7 @@ function drawingReducer(state: DrawingState, action: DrawingAction): DrawingStat case 'SET_ERASER_SIZE': return { ...state, eraserSize: action.size }; case 'SET_HISTORY_STATE': - return { - ...state, - canUndo: action.canUndo, - canRedo: action.canRedo, - hasUnsavedChanges: true, - }; - case 'SET_UNSAVED_CHANGES': - return { ...state, hasUnsavedChanges: action.hasChanges }; - case 'MARK_AS_SAVED': - return { ...state, hasUnsavedChanges: false }; + return { ...state, canUndo: action.canUndo, canRedo: action.canRedo }; case 'RESET': return initialState; default: @@ -82,10 +69,6 @@ export function useDrawingState() { dispatch({ type: 'SET_HISTORY_STATE', canUndo, canRedo }); }, []); - const markAsSaved = useCallback(() => { - dispatch({ type: 'MARK_AS_SAVED' }); - }, []); - const reset = useCallback(() => { dispatch({ type: 'RESET' }); }, []); @@ -100,7 +83,6 @@ export function useDrawingState() { eraserSize: state.eraserSize, canUndo: state.canUndo, canRedo: state.canRedo, - hasUnsavedChanges: state.hasUnsavedChanges, // Actions setPenMode, @@ -109,7 +91,6 @@ export function useDrawingState() { setStrokeWidth, setEraserSize, setHistoryState, - markAsSaved, reset, }; } diff --git a/apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts b/apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts index d5e396044..7f7152329 100644 --- a/apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts +++ b/apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts @@ -1,154 +1,199 @@ -import { useEffect, useCallback, useRef } from 'react'; +/** + * 필기 매니저 — canvas 바인딩 / decode / autosave / explicit flush 오케스트레이션. + * + * 책임: + * - canvas ref + mounted state (useImperativeHandle deps 우회용) + * - 필기 데이터 로드 (decode) + * - autosave (5s interval) / AppState background → 큐 enqueueAutosave + * - explicit flush (onBack/swipe/탭) → 큐 flushExplicit + 결과별 Alert + * - unmount cleanup → 큐 dequeue (화면 떠난 후 retry 안 함) + * + * 합의: 화면 떠나는 시점 = success or 사용자 명시 discard. 둘 중 하나로만 unmount. + */ +import { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, AppState, type AppStateStatus } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; -import { useGetHandwriting, useUpdateHandwriting } from '@/apis'; +import { TanstackQueryClient, useGetHandwriting } from '@apis'; -import { type DrawingCanvasRef } from '../utils/skia/drawing'; -import { encodeHandwritingData, decodeHandwritingData } from '../utils/handwritingEncoder'; +import { handwritingSaveQueue } from '../services'; +import { type DrawingCanvasRef, type Stroke, type TextItem } from '../utils/skia/drawing'; +import { decodeHandwritingData } from '../utils/handwritingDecoder'; + +import { runExplicitFlushLoop } from './handwritingFlushPending'; + +const AUTOSAVE_INTERVAL_MS = 5_000; export interface UseHandwritingManagerProps { scrapId: number; - canvasRef: React.RefObject; - hasUnsavedChanges: boolean; - onSaveSuccess?: () => void; - onSaveError?: () => void; } -export function useHandwritingManager({ - scrapId, - canvasRef, - hasUnsavedChanges, - onSaveSuccess, - onSaveError, -}: UseHandwritingManagerProps) { +export function useHandwritingManager({ scrapId }: UseHandwritingManagerProps) { const { data: handwritingData, isLoading } = useGetHandwriting(scrapId, !!scrapId); - const { mutate: updateHandwriting, isPending: isSaving } = useUpdateHandwriting(); - const lastSavedDataRef = useRef(''); - const currentScrapIdRef = useRef(scrapId); - - // scrapId가 변경되면 lastSavedDataRef 초기화 + const queryClient = useQueryClient(); + + // canvas 인스턴스는 ref, mount 사실은 boolean state 로 분리. + // useImperativeHandle (drawing.tsx) 가 deps 없이 매 render 마다 새 object 로 ref 를 + // 업데이트하므로 object 자체를 useState 에 담으면 무한 루프. boolean 은 dedupe 로 안전. + const canvasRef = useRef(null); + const [canvasMounted, setCanvasMounted] = useState(false); + const setCanvasRef = useCallback((node: DrawingCanvasRef | null) => { + canvasRef.current = node; + setCanvasMounted(node !== null); + }, []); + + const needsSaveRef = useRef(false); + const pendingLoadRef = useRef(false); + const appliedScrapIdRef = useRef(null); + const [decodeError, setDecodeError] = useState(null); + + // scrapId 변경 시 reset useEffect(() => { - if (currentScrapIdRef.current !== scrapId) { - lastSavedDataRef.current = ''; - currentScrapIdRef.current = scrapId; + appliedScrapIdRef.current = null; + needsSaveRef.current = false; + setDecodeError(null); + pendingLoadRef.current = true; + try { + canvasRef.current?.clear(); + } finally { + pendingLoadRef.current = false; } }, [scrapId]); - // 필기 데이터 로드 + // 데이터 로드 — canvas mount + handwritingData 도착 후 useEffect(() => { - // 저장 중이 아니고, scrapId가 일치할 때만 로드 (데이터 유실 방지) - if ( - handwritingData?.data && - canvasRef.current && - currentScrapIdRef.current === scrapId && - !isSaving - ) { - // clear() 완료를 보장하기 위해 약간의 지연 후 로드 - const loadTimer = setTimeout(() => { - // 다시 한번 scrapId 확인 (clear() 실행 중일 수 있음) - if (currentScrapIdRef.current === scrapId && canvasRef.current && !isSaving) { - try { - const decodedData = decodeHandwritingData(handwritingData.data); - canvasRef.current.setStrokes(decodedData.strokes); - canvasRef.current.setTexts(decodedData.texts); - lastSavedDataRef.current = handwritingData.data; - } catch (error) { - console.error('필기 데이터 로드 실패:', error); - } - } - }, 50); // 50ms 지연으로 clear() 완료 보장 - - return () => clearTimeout(loadTimer); + if (handwritingData === undefined) return; + if (appliedScrapIdRef.current === scrapId) return; + if (!canvasMounted) return; + try { + const decoded = handwritingData + ? decodeHandwritingData(handwritingData) + : { strokes: [] as Stroke[], texts: [] as TextItem[] }; + pendingLoadRef.current = true; + try { + canvasRef.current?.setStrokes(decoded.strokes); + canvasRef.current?.setTexts(decoded.texts); + } finally { + pendingLoadRef.current = false; + } + appliedScrapIdRef.current = scrapId; + needsSaveRef.current = false; + } catch (e) { + console.error('[handwriting] decode failed', e); + setDecodeError('필기를 불러오지 못했어요.'); } - }, [handwritingData, canvasRef, scrapId]); + }, [handwritingData, scrapId, canvasMounted]); - // 저장하기 함수 - const handleSave = useCallback( - (isAutoSave = false, targetScrapId?: number) => { - if (!canvasRef.current) return Promise.resolve(false); - - // 이미 저장 중이면 중복 저장 방지 - if (isSaving) { - return Promise.resolve(false); - } + // unmount cleanup — 화면 떠난 후 큐 retry 안 함 (인플라이트 PUT 응답은 stale guard 로 skip) + useEffect(() => { + return () => { + handwritingSaveQueue.dequeue(scrapId); + }; + }, [scrapId]); - const strokes = canvasRef.current.getStrokes(); - const texts = canvasRef.current.getTexts(); + const markNeedsSave = useCallback(() => { + if (pendingLoadRef.current) return; + // load 적용 후에만 dirty. drawing.tsx 의 mount useEffect 가 1회 notifyHistoryChange 발화하는데 + // 그 시점에 reset effect 의 pendingLoadRef=true 윈도우가 이미 끝나있어서 이 가드가 없으면 + // needsSaveRef=true 잘못 set → 이후 load effect 가 needsSaveRef 가드로 server data skip → 빈 캔버스. + if (appliedScrapIdRef.current !== scrapId) return; + if (decodeError) return; + needsSaveRef.current = true; + }, [scrapId, decodeError]); + + const canFlush = useCallback((): boolean => { + if (!needsSaveRef.current) return false; + if (pendingLoadRef.current) return false; + if (decodeError) return false; + if (appliedScrapIdRef.current !== scrapId) return false; + if (!canvasRef.current) return false; + return true; + }, [scrapId, decodeError]); + + const buildDataJson = useCallback((): string | null => { + const c = canvasRef.current; + if (!c) return null; + return JSON.stringify({ strokes: c.getStrokes() ?? [], texts: c.getTexts() ?? [] }); + }, []); + + const handwritingQueryKey = useCallback( + (id: number) => + TanstackQueryClient.queryOptions('get', '/api/student/scrap/{scrapId}/handwriting', { + params: { path: { scrapId: id } }, + }).queryKey, + [] + ); - try { - const base64Data = encodeHandwritingData(strokes || [], texts || []); - - // 변경사항 없으면 저장 안 함 - if (base64Data === lastSavedDataRef.current) { - if (!isAutoSave) { - Alert.alert('알림', '변경사항이 없습니다.'); - } - return Promise.resolve(true); - } - - // targetScrapId가 제공되면 그것을 사용, 아니면 scrapId 사용 - const saveScrapId = targetScrapId ?? scrapId; - - return new Promise((resolve) => { - updateHandwriting( - { - scrapId: saveScrapId, - request: { data: base64Data }, - }, - { - onSuccess: () => { - lastSavedDataRef.current = base64Data; - onSaveSuccess?.(); - if (!isAutoSave) { - Alert.alert('성공', '필기가 저장되었습니다.'); - } - resolve(true); - }, - onError: (error) => { - console.error('필기 저장 실패:', error); - onSaveError?.(); - if (!isAutoSave) { - Alert.alert('오류', '필기 저장에 실패했습니다.'); - } - resolve(false); - }, - } - ); - }); - } catch (error) { - console.error('필기 데이터 변환 실패:', error); - if (!isAutoSave) { - Alert.alert('오류', '필기 데이터 변환에 실패했습니다.'); - } - return Promise.resolve(false); - } + const enqueueAutosave = useCallback( + (source: 'autosave' | 'background') => { + if (!canFlush()) return; + const dataJson = buildDataJson(); + if (dataJson === null) return; + handwritingSaveQueue.enqueueAutosave(scrapId, dataJson, source); + needsSaveRef.current = false; }, - [scrapId, canvasRef, updateHandwriting, onSaveSuccess, onSaveError, isSaving] + [scrapId, canFlush, buildDataJson] ); - // 5초마다 자동 저장 + // autosave 5s interval useEffect(() => { - const autoSaveInterval = setInterval(() => { - if (hasUnsavedChanges && !isSaving) { - handleSave(true); - } - }, 5000); // 5초마다 실행 - - return () => clearInterval(autoSaveInterval); - }, [hasUnsavedChanges, isSaving, handleSave]); + const id = setInterval(() => enqueueAutosave('autosave'), AUTOSAVE_INTERVAL_MS); + return () => clearInterval(id); + }, [enqueueAutosave]); + // AppState background useEffect(() => { - const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => { - if (nextState === 'background' && hasUnsavedChanges && !isSaving) { - handleSave(true); - } + const sub = AppState.addEventListener('change', (next: AppStateStatus) => { + if (next === 'background') enqueueAutosave('background'); }); - return () => subscription.remove(); - }, [hasUnsavedChanges, isSaving, handleSave]); + return () => sub.remove(); + }, [enqueueAutosave]); + + // explicit flush — onBack/swipe/탭/onTabClose/AllPointings 등 사용자 명시 의도. + // 로컬 dirty 가 없어도 큐에 잔존 entry (autosave retry/hold) 가 있으면 명시 PUT 으로 끌고 간다. + // autosave 실패 후 사용자가 떠나려고 할 때 silent loss 를 막고, 실패 시 Alert 로 사용자 결정 받음. + const flushPending = useCallback(async (): Promise => { + const queueHasEntry = handwritingSaveQueue.has(scrapId); + if (!queueHasEntry && !canFlush()) return; + + // PUT 못 만드는 케이스 — decode 실패 / canvas unmount → 큐만 정리 + if (decodeError || !canvasRef.current) { + handwritingSaveQueue.dequeue(scrapId); + return; + } + const dataJson = buildDataJson(); + if (dataJson === null) { + handwritingSaveQueue.dequeue(scrapId); + return; + } + + await runExplicitFlushLoop({ + scrapId, + dataJson, + queue: handwritingSaveQueue, + showRetryAlert: () => + new Promise<'retry' | 'discard'>((resolve) => { + Alert.alert('저장에 실패했어요', '', [ + { text: '확인', style: 'destructive', onPress: () => resolve('discard') }, + { text: '다시 시도', onPress: () => resolve('retry') }, + ]); + }), + onDiscard: () => { + queryClient.removeQueries({ queryKey: handwritingQueryKey(scrapId) }); + }, + }); + needsSaveRef.current = false; + }, [scrapId, canFlush, buildDataJson, decodeError, handwritingQueryKey, queryClient]); + + const hasUnsavedChanges = useCallback(() => needsSaveRef.current, []); return { isLoading, - isSaving, - handleSave, + decodeError, + setCanvasRef, + canvasRef, + markNeedsSave, + flushPending, + hasUnsavedChanges, }; } diff --git a/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx index 67dce8bc3..c52d55739 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx @@ -22,13 +22,11 @@ import Animated, { runOnJS, } from 'react-native-reanimated'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import { useQueryClient } from '@tanstack/react-query'; import { colors } from '@/theme/tokens'; import { useNoteStore } from '@/features/student/scrap/stores/scrapNoteStore'; import { LoadingScreen } from '@/components/common'; import { - TanstackQueryClient, useGetScrapDetail, useUpdateScrapName, useGetEntireProblemPointing, @@ -37,7 +35,7 @@ import { import { type StudentRootStackParamList } from '@/navigation/student/types'; import { toAlphabetSequence } from '../utils/formatters/toAlphabetSequence'; -import DrawingCanvas, { type DrawingCanvasRef } from '../utils/skia/drawing'; +import DrawingCanvas from '../utils/skia/drawing'; import { ScrapDetailHeader } from '../components/Header/ScrapDetailHeader'; import { TabNavigator } from '../components/scrap/TabNavigator'; import { FilterBar } from '../components/scrap/FilterBar'; @@ -58,6 +56,7 @@ import { shouldShowAnalysisSection, } from '../utils/scrapFilters'; import { showToast } from '../components/Notification/Toast'; +import { handwritingSaveQueue, HandwritingSaveQueueWiring } from '../services'; import { withScrapModals } from '../hoc/withScrapModals'; import { useScrapModal } from '../contexts/ScrapModalsContext'; import { useRecentScrapStore } from '../stores/recentScrapStore'; @@ -98,7 +97,6 @@ const ScrapDetailScreen = () => { const [_scrapName, setScrapName] = useState(); const scrapName = _scrapName ?? scrapDetail?.name ?? ''; - const queryClient = useQueryClient(); React.useEffect(() => { if (scrapDetail) { @@ -138,9 +136,6 @@ const ScrapDetailScreen = () => { } }; - // Refs - const canvasRef = useRef(null); - // Custom Hooks const drawingState = useDrawingState(); const uiState = useScrapUIState(); @@ -174,26 +169,7 @@ const ScrapDetailScreen = () => { }, [refetchScrapDetail, activeNoteId, scrapId]) ); - useEffect(() => { - return () => { - queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions( - 'get', - '/api/student/scrap/{scrapId}/handwriting', - { params: { path: { scrapId } } } - ).queryKey, - }); - }; - }, [scrapId, queryClient]); - - const handwriting = useHandwritingManager({ - scrapId, - canvasRef, - hasUnsavedChanges: drawingState.hasUnsavedChanges, - onSaveSuccess: () => { - drawingState.markAsSaved(); - }, - }); + const handwriting = useHandwritingManager({ scrapId }); // Tab management const [tabLayouts, setTabLayouts] = useState>({}); @@ -209,36 +185,25 @@ const ScrapDetailScreen = () => { } }, [activeNoteId, scrapId, navigation]); - // scrapId 변경 시 모든 state 초기화 + // scrapId 변경 시 screen-scoped state 초기화 (canvas reset 은 매니저가 담당) useEffect(() => { - // 저장 중이면 초기화 지연 (데이터 유실 방지) - if (handwriting.isSaving) { - // 저장이 완료될 때까지 대기 - const checkSaveComplete = setInterval(() => { - if (!handwriting.isSaving) { - clearInterval(checkSaveComplete); - // 저장 완료 후 초기화 - drawingState.reset(); - uiState.reset(); - canvasRef.current?.clear(); - setTabLayouts({}); - } - }, 100); // 100ms마다 체크 - - return () => clearInterval(checkSaveComplete); - } - - // 저장 중이 아니면 즉시 초기화 - // Drawing state 초기화 drawingState.reset(); - // UI state 초기화 uiState.reset(); - // Canvas 초기화 - canvasRef.current?.clear(); - // Tab layouts 초기화 setTabLayouts({}); }, [scrapId]); + // DrawingCanvas onHistoryChange 안정적 reference — 인라인 함수로 전달하면 + // canvas 내부 useCallback deps 가 매 render 마다 변하면서 무한 렌더 발생. + const { setHistoryState } = drawingState; + const { markNeedsSave } = handwriting; + const handleHistoryChange = useCallback( + (canUndo: boolean, canRedo: boolean) => { + setHistoryState(canUndo, canRedo); + markNeedsSave(); + }, + [setHistoryState, markNeedsSave] + ); + // Save indicator animation interval const indicatorTimeoutRef = useRef(null); useEffect(() => { @@ -297,8 +262,25 @@ const ScrapDetailScreen = () => { scrapDetail?.pointings && scrapDetail.pointings.some((pointing) => pointing.commentContent) ); + // 화면 떠나는 모든 경로 (swipe back / hardware back / programmatic pop) 통제 — flushPending 강제. + // hook return 전체를 deps 에 넣으면 매 렌더 새 reference 라 effect 재구독 반복. + // 매니저 내부 useCallback 으로 stable 한 콜백만 분해해서 사용. + const { flushPending: flushHandwriting, hasUnsavedChanges: hasUnsavedHandwriting } = handwriting; + useEffect(() => { + const unsub = navigation.addListener('beforeRemove', (e) => { + if (!handwritingSaveQueue.has(scrapId) && !hasUnsavedHandwriting()) return; + e.preventDefault(); + void (async () => { + await flushHandwriting(); + navigation.dispatch(e.data.action); + })(); + }); + return unsub; + }, [navigation, scrapId, flushHandwriting, hasUnsavedHandwriting]); + // Handlers - const handleViewAllPointings = useCallback(() => { + const handleViewAllPointings = useCallback(async () => { + await handwriting.flushPending(); const group = convertScrapToGroup(entireProblem?.data || [], entireProblemPointing?.data || []); if (!group) return; @@ -307,7 +289,7 @@ const ScrapDetailScreen = () => { // problemSetTitle: scrapDetail?.name || '스크랩', // publishAt: scrapDetail?.createdAt, }); - }, [navigation, entireProblemPointing, entireProblem]); + }, [handwriting, navigation, entireProblemPointing, entireProblem]); const handleTabLayout = useCallback((noteId: number, event: LayoutChangeEvent) => { const { x, width } = event.nativeEvent.layout; @@ -433,6 +415,20 @@ const ScrapDetailScreen = () => { return ; } + // Decode error state + if (handwriting.decodeError) { + return ( + + {handwriting.decodeError} + navigation.goBack()} + className='rounded bg-gray-300 px-[16px] py-[8px]'> + 뒤로가기 + + + ); + } + // Error state if (!scrapDetail) { return ( @@ -449,6 +445,7 @@ const ScrapDetailScreen = () => { return ( <> + {/* Header */} @@ -458,10 +455,8 @@ const ScrapDetailScreen = () => { onScrapNameChange={handleUpdateScrapName} showSave={uiState.showSave} onBack={async () => { - const saved = await handwriting.handleSave(true, scrapId); - if (saved) { - navigation.goBack(); - } + await handwriting.flushPending(); + navigation.goBack(); }} canGoBack={navigation.canGoBack()} onMoveFolderPress={() => { @@ -476,18 +471,12 @@ const ScrapDetailScreen = () => { activeNoteId={activeNoteId} onTabPress={async (noteId) => { if (noteId === activeNoteId) return; - // 저장 중이면 탭 전환 방지 - if (handwriting.isSaving) return; - const saved = await handwriting.handleSave(true, scrapId); - if (!saved) return; + await handwriting.flushPending(); setActiveNote(noteId); }} onTabClose={async (noteId) => { if (noteId === activeNoteId) { - // 저장 중이면 탭 닫기 방지 - if (handwriting.isSaving) return; - const saved = await handwriting.handleSave(true, scrapId); - if (!saved) return; + await handwriting.flushPending(); } closeNote(noteId); }} @@ -615,8 +604,8 @@ const ScrapDetailScreen = () => { canvasRef.current?.undo()} - onRedo={() => canvasRef.current?.redo()} + onUndo={() => handwriting.canvasRef.current?.undo()} + onRedo={() => handwriting.canvasRef.current?.redo()} isEraserMode={drawingState.isEraserMode} isTextMode={drawingState.isTextMode} onPenModePress={drawingState.setPenMode} @@ -635,14 +624,13 @@ const ScrapDetailScreen = () => { isNarrow={isNarrow} /> diff --git a/apps/native/src/features/student/scrap/services/HandwritingSaveQueueWiring.tsx b/apps/native/src/features/student/scrap/services/HandwritingSaveQueueWiring.tsx new file mode 100644 index 000000000..3e5618f7a --- /dev/null +++ b/apps/native/src/features/student/scrap/services/HandwritingSaveQueueWiring.tsx @@ -0,0 +1,68 @@ +/** + * `handwritingSaveQueue` 콜백을 React Query 캐시 + 사용자 인지 toast 에 연결하는 zero-UI 컴포넌트. + * 반드시 `QueryClientProvider` 하위 + `ScrapDetailScreen` 안에 마운트. + * + * - onSaved: 서버 PUT 성공 직후 `get /handwriting` 캐시의 `dataJson` 갱신 + * (single-device 가정 — invalidate 안 함) + * - onAutosaveFailed: autosave / background 실패 시 1초 debounce toast + * (1초 안에 onSaved 가 들어오면 cancel — 일시적 5xx 의 깜박임 차단) + */ +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useRef } from 'react'; + +import { TanstackQueryClient } from '@apis'; +import { type paths } from '@schema'; + +import { showToast } from '../components/Notification/Toast'; + +import { handwritingSaveQueue } from './handwritingSaveQueueSingleton'; + +type ScrapHandwritingResp = + paths['/api/student/scrap/{scrapId}/handwriting']['get']['responses']['200']['content']['*/*']; + +const TOAST_DEBOUNCE_MS = 1_000; + +export function HandwritingSaveQueueWiring(): null { + const queryClient = useQueryClient(); + const debounceTimerRef = useRef | null>(null); + + useEffect(() => { + handwritingSaveQueue.setCallbacks({ + onSaved: ({ scrapId, data }) => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + const queryKey = TanstackQueryClient.queryOptions( + 'get', + '/api/student/scrap/{scrapId}/handwriting', + { + params: { path: { scrapId } }, + } + ).queryKey; + queryClient.setQueryData(queryKey, (prev) => ({ + ...(prev ?? { scrapId }), + dataJson: data, + data: undefined, + })); + }, + onAutosaveFailed: () => { + if (debounceTimerRef.current) return; + debounceTimerRef.current = setTimeout(() => { + showToast('error', '자동 저장에 실패했어요'); + debounceTimerRef.current = null; + }, TOAST_DEBOUNCE_MS); + }, + }); + + return () => { + handwritingSaveQueue.setCallbacks({}); + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }; + }, [queryClient]); + + return null; +} diff --git a/apps/native/src/features/student/scrap/services/__dev__/handwritingTestMode.ts b/apps/native/src/features/student/scrap/services/__dev__/handwritingTestMode.ts new file mode 100644 index 000000000..8a00a9a91 --- /dev/null +++ b/apps/native/src/features/student/scrap/services/__dev__/handwritingTestMode.ts @@ -0,0 +1,61 @@ +/** + * 필기 저장 테스트 모드 — dev only. + * + * RN 디버거 콘솔에서 `globalThis.setHwTestMode('retry')` 등 호출하여 모드 전환. + * `applyHwTestMode()` 가 null 아닌 값 반환 시 실제 PUT 안 보내고 그 outcome 시뮬레이션. + * + * 시나리오 검증 가이드: + * - 'hold' → autosave: 1초 debounce toast (10s 후 retry) / explicit: Alert (재시도/확인) + * - 'retry' → autosave: backoff (1s/2s/4s/8s/16s/30s) + 1초 debounce toast / explicit: Alert + * - 'network_error'→ retry 와 동일 (fetch throw) + * - 'slow_2s' → 2초 응답 지연. 사용자가 그 사이 onBack 누르면 race 시나리오 (큐 inflight + flushExplicit) 검증 + * - 'slow_6s' → 6초 응답 지연 → flushExplicit 5초 timeout 발동 → Alert (재시도/확인) + * - 'normal' → 모드 해제 (실제 서버 통신) + */ +import type { FlushOutcome } from '../handwritingSaveQueue'; + +export type HwTestMode = 'normal' | 'hold' | 'retry' | 'network_error' | 'slow_2s' | 'slow_6s'; + +let currentMode: HwTestMode = 'normal'; + +export function getHwTestMode(): HwTestMode { + return currentMode; +} + +export function setHwTestMode(mode: HwTestMode): void { + currentMode = mode; + console.log('[handwriting test mode]', mode); +} + +if (__DEV__) { + (globalThis as unknown as { setHwTestMode: typeof setHwTestMode }).setHwTestMode = setHwTestMode; + console.log( + '[handwriting test mode] available — globalThis.setHwTestMode(' + + "'hold' | 'retry' | 'network_error' | 'slow_2s' | 'slow_6s' | 'normal'" + + ')' + ); +} + +/** + * 모드별 응답 시뮬레이션. + * @returns FlushOutcome 시뮬레이션 (즉시 outcome 반환) / null (실제 PUT 진행) + * @throws 'network_error' 모드에서 throw + */ +export async function applyHwTestMode(): Promise { + switch (currentMode) { + case 'normal': + return null; + case 'hold': + return 'hold'; + case 'retry': + return 'retry'; + case 'network_error': + throw new Error('[hw test mode] simulated network error'); + case 'slow_2s': + await new Promise((r) => setTimeout(r, 2_000)); + return null; + case 'slow_6s': + await new Promise((r) => setTimeout(r, 6_000)); + return null; + } +} diff --git a/apps/native/src/features/student/scrap/services/__tests__/handwritingSaveQueue.test.ts b/apps/native/src/features/student/scrap/services/__tests__/handwritingSaveQueue.test.ts new file mode 100644 index 000000000..ecac9553c --- /dev/null +++ b/apps/native/src/features/student/scrap/services/__tests__/handwritingSaveQueue.test.ts @@ -0,0 +1,362 @@ +import { + backoffDelayMs, + HandwritingSaveQueue, + type FlushOutcome, + type HandwritingQueueEntry, + type QueuePoster, +} from '../handwritingSaveQueue'; + +const SCRAP_ID = 100; +const DATA = '{"strokes":[],"texts":[]}'; + +const makePoster = (outcome: FlushOutcome): jest.MockedFunction => + jest.fn, [HandwritingQueueEntry]>().mockResolvedValue(outcome); + +describe('backoffDelayMs', () => { + it('returns 0 for attempt <= 0', () => { + expect(backoffDelayMs(0)).toBe(0); + expect(backoffDelayMs(-1)).toBe(0); + }); + + it('follows exponential backoff: [1s, 2s, 4s, 8s, 16s, 30s cap]', () => { + expect(backoffDelayMs(1)).toBe(1000); + expect(backoffDelayMs(2)).toBe(2000); + expect(backoffDelayMs(3)).toBe(4000); + expect(backoffDelayMs(4)).toBe(8000); + expect(backoffDelayMs(5)).toBe(16_000); + expect(backoffDelayMs(6)).toBe(30_000); + expect(backoffDelayMs(100)).toBe(30_000); + }); +}); + +describe('HandwritingSaveQueue — enqueueAutosave', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('success → dequeue + onSaved fire', async () => { + const poster = makePoster('success'); + const q = new HandwritingSaveQueue(poster); + const onSaved = jest.fn(); + q.setCallbacks({ onSaved }); + + q.enqueueAutosave(SCRAP_ID, DATA, 'autosave'); + expect(q.snapshot()).toHaveLength(1); + expect(q.snapshot()[0]).toMatchObject({ + scrapId: SCRAP_ID, + data: DATA, + source: 'autosave', + version: 1, + }); + + await jest.advanceTimersByTimeAsync(0); + + expect(poster).toHaveBeenCalledTimes(1); + expect(q.snapshot()).toHaveLength(0); + expect(onSaved).toHaveBeenCalledWith({ scrapId: SCRAP_ID, data: DATA, version: 1 }); + }); + + it('hold (401/403) → retain + retry after 10s + onAutosaveFailed("hold")', async () => { + const poster = makePoster('hold'); + const q = new HandwritingSaveQueue(poster); + const onAutosaveFailed = jest.fn(); + q.setCallbacks({ onAutosaveFailed }); + + q.enqueueAutosave(SCRAP_ID, DATA, 'autosave'); + await jest.advanceTimersByTimeAsync(0); + + expect(q.snapshot()).toHaveLength(1); + expect(q.snapshot()[0].attempt).toBe(0); + expect(poster).toHaveBeenCalledTimes(1); + expect(onAutosaveFailed).toHaveBeenCalledWith({ + scrapId: SCRAP_ID, + outcome: 'hold', + version: 1, + }); + + await jest.advanceTimersByTimeAsync(9_999); + expect(poster).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(1); + expect(poster).toHaveBeenCalledTimes(2); + }); + + it('retry (5xx/network) → exponential backoff + onAutosaveFailed("retry")', async () => { + const poster = makePoster('retry'); + const q = new HandwritingSaveQueue(poster); + const onAutosaveFailed = jest.fn(); + q.setCallbacks({ onAutosaveFailed }); + + q.enqueueAutosave(SCRAP_ID, DATA, 'autosave'); + + await jest.advanceTimersByTimeAsync(0); + expect(poster).toHaveBeenCalledTimes(1); + expect(q.snapshot()[0].attempt).toBe(1); + expect(onAutosaveFailed).toHaveBeenLastCalledWith({ + scrapId: SCRAP_ID, + outcome: 'retry', + version: 1, + }); + + await jest.advanceTimersByTimeAsync(1000); + expect(poster).toHaveBeenCalledTimes(2); + await jest.advanceTimersByTimeAsync(2000); + expect(poster).toHaveBeenCalledTimes(3); + await jest.advanceTimersByTimeAsync(4000); + expect(poster).toHaveBeenCalledTimes(4); + }); + + it('dedup by scrapId: data overwrite, attempt reset, version 증가', async () => { + const poster = makePoster('retry'); + const q = new HandwritingSaveQueue(poster); + + q.enqueueAutosave(SCRAP_ID, DATA, 'autosave'); + await jest.advanceTimersByTimeAsync(0); + expect(q.snapshot()[0].version).toBe(1); + expect(q.snapshot()[0].attempt).toBe(1); + + const newData = '{"strokes":[{"id":"a"}],"texts":[]}'; + q.enqueueAutosave(SCRAP_ID, newData, 'autosave'); + expect(q.snapshot()[0].data).toBe(newData); + expect(q.snapshot()[0].attempt).toBe(0); + expect(q.snapshot()[0].version).toBe(2); + + await jest.advanceTimersByTimeAsync(0); + expect(poster).toHaveBeenCalledTimes(2); + expect(poster.mock.calls[1][0].data).toBe(newData); + }); + + it('multi-scrapId entries coexist', async () => { + const poster = makePoster('success'); + const q = new HandwritingSaveQueue(poster); + + q.enqueueAutosave(1, DATA, 'autosave'); + q.enqueueAutosave(2, DATA, 'autosave'); + q.enqueueAutosave(3, DATA, 'background'); + expect(q.snapshot()).toHaveLength(3); + + await jest.advanceTimersByTimeAsync(0); + expect(poster).toHaveBeenCalledTimes(3); + expect(q.snapshot()).toHaveLength(0); + }); + + it('onAutosaveFailed only fires for autosave/background (not explicit)', async () => { + const poster = makePoster('retry'); + const q = new HandwritingSaveQueue(poster); + const onAutosaveFailed = jest.fn(); + q.setCallbacks({ onAutosaveFailed }); + + void q.flushExplicit(SCRAP_ID, DATA); + await jest.advanceTimersByTimeAsync(0); + + expect(onAutosaveFailed).not.toHaveBeenCalled(); + }); +}); + +describe('HandwritingSaveQueue — flushExplicit (1회 시도 spec)', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('success → resolve("success") + dequeue + onSaved', async () => { + const poster = makePoster('success'); + const q = new HandwritingSaveQueue(poster); + const onSaved = jest.fn(); + q.setCallbacks({ onSaved }); + + const promise = q.flushExplicit(SCRAP_ID, DATA); + await jest.advanceTimersByTimeAsync(0); + const result = await promise; + + expect(result).toEqual({ outcome: 'success', version: 1 }); + expect(q.has(SCRAP_ID)).toBe(false); + expect(onSaved).toHaveBeenCalledWith({ scrapId: SCRAP_ID, data: DATA, version: 1 }); + }); + + it('retry → resolve("retry") + dequeue (autosave 와 달리 backoff 안 함)', async () => { + const poster = makePoster('retry'); + const q = new HandwritingSaveQueue(poster); + + const promise = q.flushExplicit(SCRAP_ID, DATA); + await jest.advanceTimersByTimeAsync(0); + const result = await promise; + + expect(result).toEqual({ outcome: 'retry', version: 1 }); + expect(q.has(SCRAP_ID)).toBe(false); + expect(poster).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(60_000); + expect(poster).toHaveBeenCalledTimes(1); // 1회 시도 spec — 추가 retry 안 함 + }); + + it('hold → resolve("hold") + dequeue', async () => { + const poster = makePoster('hold'); + const q = new HandwritingSaveQueue(poster); + + const promise = q.flushExplicit(SCRAP_ID, DATA); + await jest.advanceTimersByTimeAsync(0); + const result = await promise; + + expect(result).toEqual({ outcome: 'hold', version: 1 }); + expect(q.has(SCRAP_ID)).toBe(false); + }); + + it('5s timeout → resolve("timeout") + dequeue', async () => { + let resolvePoster!: (v: FlushOutcome) => void; + const poster = jest.fn, [HandwritingQueueEntry]>().mockImplementation( + () => + new Promise((resolve) => { + resolvePoster = resolve; + }) + ); + const q = new HandwritingSaveQueue(poster); + + const promise = q.flushExplicit(SCRAP_ID, DATA); + await jest.advanceTimersByTimeAsync(0); + expect(poster).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(5_000); + const result = await promise; + + expect(result).toEqual({ outcome: 'timeout', version: 1 }); + expect(q.has(SCRAP_ID)).toBe(false); + + // 후행 응답 도착해도 stale 처리 + resolvePoster('success'); + await jest.advanceTimersByTimeAsync(0); + }); + + it('explicit waiter 살아있는 동안 enqueueAutosave skip', async () => { + let resolvePoster!: (v: FlushOutcome) => void; + const poster = jest.fn, [HandwritingQueueEntry]>().mockImplementation( + () => + new Promise((resolve) => { + resolvePoster = resolve; + }) + ); + const q = new HandwritingSaveQueue(poster); + + const promise = q.flushExplicit(SCRAP_ID, DATA); + await jest.advanceTimersByTimeAsync(0); + + // explicit inflight 중 autosave enqueue → skip + q.enqueueAutosave(SCRAP_ID, '{"different":1}', 'autosave'); + expect(q.snapshot()[0].source).toBe('explicit'); + expect(q.snapshot()[0].data).toBe(DATA); + expect(q.snapshot()[0].version).toBe(1); + + resolvePoster('success'); + await jest.advanceTimersByTimeAsync(0); + await promise; + }); +}); + +describe('HandwritingSaveQueue — version stale guard', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('inflight V1 응답 도착 시점에 V2 가 enqueue 됐으면 V1 outcome stale skip', async () => { + let resolvePoster!: (v: FlushOutcome) => void; + const poster = jest.fn, [HandwritingQueueEntry]>().mockImplementation( + () => + new Promise((resolve) => { + resolvePoster = resolve; + }) + ); + const q = new HandwritingSaveQueue(poster); + const onAutosaveFailed = jest.fn(); + q.setCallbacks({ onAutosaveFailed }); + + q.enqueueAutosave(SCRAP_ID, DATA, 'autosave'); // V1 + await jest.advanceTimersByTimeAsync(0); + expect(poster).toHaveBeenCalledTimes(1); + expect(q.snapshot()[0].version).toBe(1); + + // V1 inflight 중 V2 enqueue (dedup overwrite) + const newData = '{"strokes":[{"id":"v2"}],"texts":[]}'; + q.enqueueAutosave(SCRAP_ID, newData, 'autosave'); // V2 + expect(q.snapshot()[0].version).toBe(2); + expect(q.snapshot()[0].data).toBe(newData); + expect(q.snapshot()[0].attempt).toBe(0); + + // V1 응답 도착 (retry) → version stale → entries 갱신/onAutosaveFailed skip + resolvePoster('retry'); + await jest.advanceTimersByTimeAsync(0); + + expect(q.snapshot()[0].version).toBe(2); // V2 그대로 + expect(q.snapshot()[0].attempt).toBe(0); // V1 retry 가 attempt 못 올림 + // onAutosaveFailed 는 V2 의 응답 처리 시점에 fire — 본 케이스에서 V2 도 retry 응답 + expect(poster).toHaveBeenCalledTimes(2); + }); + + it('cleanup dequeue 후 inflight success 응답 → onSaved 는 fire (stale guard 우회)', async () => { + let resolvePoster!: (v: FlushOutcome) => void; + const poster = jest.fn, [HandwritingQueueEntry]>().mockImplementation( + () => + new Promise((resolve) => { + resolvePoster = resolve; + }) + ); + const q = new HandwritingSaveQueue(poster); + const onSaved = jest.fn(); + q.setCallbacks({ onSaved }); + + q.enqueueAutosave(SCRAP_ID, DATA, 'autosave'); + await jest.advanceTimersByTimeAsync(0); + + // unmount cleanup 시뮬레이션 + q.dequeue(SCRAP_ID); + expect(q.has(SCRAP_ID)).toBe(false); + + // inflight 응답 success 도착 + resolvePoster('success'); + await jest.advanceTimersByTimeAsync(0); + + // onSaved 는 fire 되어야 — cache 갱신 보장 + expect(onSaved).toHaveBeenCalledWith({ scrapId: SCRAP_ID, data: DATA, version: 1 }); + }); +}); + +describe('HandwritingSaveQueue — utility', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('has() reflects current entries', async () => { + const poster = makePoster('success'); + const q = new HandwritingSaveQueue(poster); + + expect(q.has(SCRAP_ID)).toBe(false); + q.enqueueAutosave(SCRAP_ID, DATA, 'autosave'); + expect(q.has(SCRAP_ID)).toBe(true); + + await jest.advanceTimersByTimeAsync(0); + expect(q.has(SCRAP_ID)).toBe(false); + }); + + it('dequeue removes entry from Map (inflight 자체는 abort 안 함)', async () => { + const poster = makePoster('success'); + const q = new HandwritingSaveQueue(poster); + + q.enqueueAutosave(SCRAP_ID, DATA, 'autosave'); + expect(q.has(SCRAP_ID)).toBe(true); + + q.dequeue(SCRAP_ID); + expect(q.has(SCRAP_ID)).toBe(false); + }); + + it('_reset clears all internal state', async () => { + const poster = makePoster('success'); + const q = new HandwritingSaveQueue(poster); + const onSaved = jest.fn(); + q.setCallbacks({ onSaved }); + + q.enqueueAutosave(SCRAP_ID, DATA, 'autosave'); + expect(q.snapshot()).toHaveLength(1); + + q._reset(); + expect(q.snapshot()).toHaveLength(0); + + onSaved.mockClear(); + q.enqueueAutosave(SCRAP_ID, DATA, 'autosave'); + await jest.advanceTimersByTimeAsync(0); + expect(onSaved).not.toHaveBeenCalled(); // _reset 으로 콜백 해제 + }); +}); diff --git a/apps/native/src/features/student/scrap/services/handwritingSavePoster.ts b/apps/native/src/features/student/scrap/services/handwritingSavePoster.ts new file mode 100644 index 000000000..ad29d1bdf --- /dev/null +++ b/apps/native/src/features/student/scrap/services/handwritingSavePoster.ts @@ -0,0 +1,36 @@ +/** + * `handwritingSaveQueue` 가 사용할 real-world poster. + * 단일 entry 를 서버에 PUT 하고 HTTP 응답을 `FlushOutcome` 로 분류한다. + * + * 분류 기준: + * - 2xx → success + * - 401 / 403 → hold (auth 만료 가능성, 대기 후 재시도) + * - 그 외 (4xx / 5xx / fetch throw) → retry + * (4xx 도 일시적 가능성 — 서버 schema drift / 422 일시 validation / 429 등. + * 영영 retry 만 옳지 않은 영구 4xx 케이스는 화면 안에서 사용자가 명시 discard 로 종료) + * + * dev only: `globalThis.setHwTestMode(...)` 로 응답 시뮬레이션 가능 (production 영향 0). + */ +import { client } from '@apis'; + +import type { FlushOutcome, HandwritingQueueEntry } from './handwritingSaveQueue'; +import { applyHwTestMode } from './__dev__/handwritingTestMode'; + +export async function postHandwritingSave(entry: HandwritingQueueEntry): Promise { + try { + if (__DEV__) { + const mocked = await applyHwTestMode(); + if (mocked !== null) return mocked; + } + const res = await client.PUT('/api/student/scrap/{scrapId}/handwriting', { + params: { path: { scrapId: entry.scrapId } }, + body: { dataJson: entry.data }, + }); + if (res.response.ok) return 'success'; + const status = res.response.status; + if (status === 401 || status === 403) return 'hold'; + return 'retry'; + } catch { + return 'retry'; + } +} diff --git a/apps/native/src/features/student/scrap/services/handwritingSaveQueue.ts b/apps/native/src/features/student/scrap/services/handwritingSaveQueue.ts new file mode 100644 index 000000000..442399024 --- /dev/null +++ b/apps/native/src/features/student/scrap/services/handwritingSaveQueue.ts @@ -0,0 +1,223 @@ +/** + * 필기 저장 큐 — version 기반 race 봉쇄 + 화면 안에서만 retry. + * + * 책임: + * - autosave / AppState background — backoff retry, 사용자 인지 콜백 + * - explicit flush (사용자 onBack/탭/swipe) — 1회 시도, 결과 반환 + * - 단일 inflight 직렬화 (flushLock) + * - scrapId 별 version 단조 증가, version stale guard + * + * 정합성 가정: 화면 unmount 시 매니저가 dequeue 호출 → 화면 떠난 후 retry 안 함. + * inflight PUT 자체는 abort 못 하므로 응답 도착 시 version stale guard 로 skip. + */ + +export type SaveSource = 'autosave' | 'background' | 'explicit'; +export type FlushOutcome = 'success' | 'hold' | 'retry'; + +export interface HandwritingQueueEntry { + scrapId: number; + data: string; + version: number; + attempt: number; + nextAttemptAt: number; + source: SaveSource; +} + +export interface ExplicitFlushResult { + outcome: FlushOutcome | 'timeout'; + version: number; +} + +export interface QueueCallbacks { + onSaved?: (e: { scrapId: number; data: string; version: number }) => void; + onAutosaveFailed?: (e: { scrapId: number; outcome: 'hold' | 'retry'; version: number }) => void; +} + +export type QueuePoster = (entry: HandwritingQueueEntry) => Promise; + +const FLUSH_TIMEOUT_MS = 5_000; +const HOLD_DELAY_MS = 10_000; + +export function backoffDelayMs(attempt: number): number { + if (attempt <= 0) return 0; + return Math.min(1000 * 2 ** (attempt - 1), 30_000); +} + +export class HandwritingSaveQueue { + private readonly entries = new Map(); + private readonly waiters = new Map void>(); + // 큐 lifetime 동안 globally unique. scrapId 별 분리 시 동일 version 충돌 → waiter mis-resolve 가능. + private versionCounter = 0; + private callbacks: QueueCallbacks = {}; + private flushLock = false; + private timer: ReturnType | null = null; + + constructor(private readonly poster: QueuePoster) {} + + setCallbacks(cb: QueueCallbacks): void { + this.callbacks = cb; + } + + /** + * autosave / AppState background 경로. + * explicit waiter 가 살아있는 동안엔 skip — explicit 의 source/version 보존. + */ + enqueueAutosave(scrapId: number, data: string, source: 'autosave' | 'background'): void { + const existing = this.entries.get(scrapId); + if (existing?.source === 'explicit' && this.waiters.has(existing.version)) { + return; + } + const version = this.nextVersion(); + this.entries.set(scrapId, { + scrapId, + data, + version, + attempt: 0, + nextAttemptAt: Date.now(), + source, + }); + this.scheduleFlush(0); + } + + /** + * explicit flush — 1회 시도 후 결과 반환. 매니저가 outcome 보고 Alert 결정. + * 5s timeout. retry/hold 모두 즉시 dequeue + waiter 통보 (autosave 와 달리 backoff 안 함). + */ + flushExplicit(scrapId: number, data: string): Promise { + const version = this.nextVersion(); + this.entries.set(scrapId, { + scrapId, + data, + version, + attempt: 0, + nextAttemptAt: Date.now(), + source: 'explicit', + }); + return new Promise((resolve) => { + const timer = setTimeout(() => { + if (!this.waiters.has(version)) return; + this.waiters.delete(version); + const cur = this.entries.get(scrapId); + if (cur && cur.version === version) this.entries.delete(scrapId); + resolve({ outcome: 'timeout', version }); + }, FLUSH_TIMEOUT_MS); + this.waiters.set(version, (result) => { + clearTimeout(timer); + resolve(result); + }); + this.scheduleFlush(0); + }); + } + + /** + * scrapId 의 entry 제거 (entries Map 만; inflight PUT 자체는 abort 안 됨). + * inflight 응답 도착 시 version stale guard 로 갱신 skip. + */ + dequeue(scrapId: number): void { + this.entries.delete(scrapId); + } + + has(scrapId: number): boolean { + return this.entries.has(scrapId); + } + + snapshot(): HandwritingQueueEntry[] { + return [...this.entries.values()]; + } + + /** 테스트 헬퍼. 실서비스 미사용. */ + _reset(): void { + this.entries.clear(); + this.waiters.clear(); + this.versionCounter = 0; + this.callbacks = {}; + this.flushLock = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private nextVersion(): number { + return ++this.versionCounter; + } + + private scheduleFlush(ms: number): void { + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = null; + void this.flush(); + }, ms); + } + + private scheduleNext(): void { + if (this.entries.size === 0) return; + const nextAt = Math.min(...[...this.entries.values()].map((e) => e.nextAttemptAt)); + this.scheduleFlush(Math.max(0, nextAt - Date.now())); + } + + private async flush(): Promise { + if (this.flushLock) return; + this.flushLock = true; + try { + const flushStartedAt = Date.now(); + const due = [...this.entries.values()].filter((e) => e.nextAttemptAt <= flushStartedAt); + for (const entry of due) { + const outcome = await this.poster(entry); + this.applyOutcome(entry, outcome, Date.now()); + } + } finally { + this.flushLock = false; + this.scheduleNext(); + } + } + + private applyOutcome(entry: HandwritingQueueEntry, outcome: FlushOutcome, now: number): void { + // success — version 단일 식별로 stale guard 우회. cleanup dequeue 후도 onSaved fire. + if (outcome === 'success') { + const cur = this.entries.get(entry.scrapId); + if (cur && cur.version === entry.version) this.entries.delete(entry.scrapId); + this.callbacks.onSaved?.({ + scrapId: entry.scrapId, + data: entry.data, + version: entry.version, + }); + this.resolveWaiter(entry.version, { outcome: 'success', version: entry.version }); + return; + } + + // version stale (새 enqueue 들어왔거나 dequeue 됨) → entries 갱신 skip, waiter 만 통보 + const current = this.entries.get(entry.scrapId); + if (!current || current.version !== entry.version) { + this.resolveWaiter(entry.version, { outcome, version: entry.version }); + return; + } + + // explicit — 1회 시도. 모든 outcome 즉시 dequeue + waiter 통보 + if (entry.source === 'explicit') { + this.entries.delete(entry.scrapId); + this.resolveWaiter(entry.version, { outcome, version: entry.version }); + return; + } + + // autosave / background — backoff/hold 누적 + 인지 콜백 + if (outcome === 'hold') { + current.nextAttemptAt = now + HOLD_DELAY_MS; + } else { + current.attempt += 1; + current.nextAttemptAt = now + backoffDelayMs(current.attempt); + } + this.callbacks.onAutosaveFailed?.({ + scrapId: entry.scrapId, + outcome, + version: entry.version, + }); + } + + private resolveWaiter(version: number, result: ExplicitFlushResult): void { + const waiter = this.waiters.get(version); + if (!waiter) return; + this.waiters.delete(version); + waiter(result); + } +} diff --git a/apps/native/src/features/student/scrap/services/handwritingSaveQueueSingleton.ts b/apps/native/src/features/student/scrap/services/handwritingSaveQueueSingleton.ts new file mode 100644 index 000000000..56fee531b --- /dev/null +++ b/apps/native/src/features/student/scrap/services/handwritingSaveQueueSingleton.ts @@ -0,0 +1,11 @@ +/** + * 앱 전역 handwritingSaveQueue singleton. + * + * `services/index.ts` 에서 함께 만들면 `HandwritingSaveQueueWiring` → `./index` → + * `HandwritingSaveQueueWiring` 순환 의존성이 생긴다. 별도 파일로 분리해 + * Wiring / 매니저가 동일 singleton 을 cycle 없이 import 한다. + */ +import { HandwritingSaveQueue } from './handwritingSaveQueue'; +import { postHandwritingSave } from './handwritingSavePoster'; + +export const handwritingSaveQueue = new HandwritingSaveQueue(postHandwritingSave); diff --git a/apps/native/src/features/student/scrap/services/index.ts b/apps/native/src/features/student/scrap/services/index.ts new file mode 100644 index 000000000..9a1fb9862 --- /dev/null +++ b/apps/native/src/features/student/scrap/services/index.ts @@ -0,0 +1,13 @@ +export { + HandwritingSaveQueue, + backoffDelayMs, + type FlushOutcome, + type HandwritingQueueEntry, + type QueuePoster, + type SaveSource, + type ExplicitFlushResult, + type QueueCallbacks, +} from './handwritingSaveQueue'; +export { postHandwritingSave } from './handwritingSavePoster'; +export { handwritingSaveQueue } from './handwritingSaveQueueSingleton'; +export { HandwritingSaveQueueWiring } from './HandwritingSaveQueueWiring'; diff --git a/apps/native/src/features/student/scrap/utils/handwritingDecoder.ts b/apps/native/src/features/student/scrap/utils/handwritingDecoder.ts new file mode 100644 index 000000000..52b5d5fa3 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/handwritingDecoder.ts @@ -0,0 +1,33 @@ +import { type Stroke, type TextItem } from './skia/drawing'; + +export interface HandwritingData { + strokes: Stroke[]; + texts: TextItem[]; +} + +type DecodeSource = { + dataJson?: string | null; + data?: string | null; +}; + +function parseHandwriting(jsonString: string): HandwritingData { + const parsed = JSON.parse(jsonString); + if (Array.isArray(parsed)) { + return { strokes: parsed, texts: [] }; + } + return { + strokes: parsed.strokes ?? [], + texts: parsed.texts ?? [], + }; +} + +export function decodeHandwritingData(source: DecodeSource): HandwritingData { + if (source.dataJson && source.dataJson.length > 0) { + return parseHandwriting(source.dataJson); + } + if (source.data && source.data.length > 0) { + const decoded = decodeURIComponent(escape(atob(source.data))); + return parseHandwriting(decoded); + } + return { strokes: [], texts: [] }; +} diff --git a/apps/native/src/features/student/scrap/utils/handwritingEncoder.ts b/apps/native/src/features/student/scrap/utils/handwritingEncoder.ts deleted file mode 100644 index d7cf79d38..000000000 --- a/apps/native/src/features/student/scrap/utils/handwritingEncoder.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { type Stroke, type TextItem } from './skia/drawing'; - -export interface HandwritingData { - strokes: Stroke[]; - texts: TextItem[]; -} - -/** - * 필기 데이터를 Base64로 인코딩합니다. - * @param strokes - 그리기 스트로크 배열 - * @param texts - 텍스트 아이템 배열 - * @returns Base64로 인코딩된 문자열 - */ -export function encodeHandwritingData(strokes: Stroke[], texts: TextItem[]): string { - const data: HandwritingData = { - strokes: strokes || [], - texts: texts || [], - }; - const jsonString = JSON.stringify(data); - const base64Data = btoa(unescape(encodeURIComponent(jsonString))); - return base64Data; -} - -/** - * Base64로 인코딩된 필기 데이터를 디코딩합니다. - * @param base64Data - Base64로 인코딩된 문자열 - * @returns 디코딩된 필기 데이터 (strokes, texts) - * @throws 디코딩 실패 시 에러 - */ -export function decodeHandwritingData(base64Data: string): HandwritingData { - try { - const decodedData = decodeURIComponent(escape(atob(base64Data))); - const data = JSON.parse(decodedData); - - // 이전 형식 호환성: strokes만 있는 경우와 { strokes, texts } 형식 모두 지원 - if (Array.isArray(data)) { - // 이전 형식: strokes 배열만 - return { - strokes: data, - texts: [], - }; - } else { - // 새 형식: { strokes, texts } 객체 - return { - strokes: data.strokes || [], - texts: data.texts || [], - }; - } - } catch (error) { - console.error('필기 데이터 디코딩 실패:', error); - throw error; - } -} - -/** - * 두 필기 데이터가 동일한지 비교합니다. - * @param data1 - 첫 번째 Base64 인코딩된 데이터 - * @param data2 - 두 번째 Base64 인코딩된 데이터 - * @returns 동일 여부 - */ -export function isSameHandwritingData(data1: string, data2: string): boolean { - return data1 === data2; -} diff --git a/apps/native/src/types/api/schema.d.ts b/apps/native/src/types/api/schema.d.ts index 673e15b12..5812cbf09 100644 --- a/apps/native/src/types/api/schema.d.ts +++ b/apps/native/src/types/api/schema.d.ts @@ -81,6 +81,25 @@ export interface paths { patch?: never; trace?: never; }; + '/api/student/study/problem/{publishId}/{problemId}/handwriting': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 필기 데이터 조회 */ + get: operations['getHandwriting']; + /** 필기 데이터 저장/수정 */ + put: operations['updateHandwriting']; + post?: never; + /** 필기 데이터 삭제 */ + delete: operations['deleteHandwriting']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/scrap/{scrapId}': { parameters: { query?: never; @@ -157,12 +176,12 @@ export interface paths { cookie?: never; }; /** 필기 데이터 조회 */ - get: operations['getHandwriting']; + get: operations['getHandwriting_1']; /** 필기 데이터 저장/수정 */ - put: operations['updateHandwriting']; + put: operations['updateHandwriting_1']; post?: never; /** 필기 데이터 삭제 */ - delete: operations['deleteHandwriting']; + delete: operations['deleteHandwriting_1']; options?: never; head?: never; patch?: never; @@ -401,6 +420,24 @@ export interface paths { patch?: never; trace?: never; }; + '/api/admin/role/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 역할 수정 */ + put: operations['update_4']; + post?: never; + /** 역할 삭제 */ + delete: operations['delete_2']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/admin/qna/{qnaId}/status': { parameters: { query?: never; @@ -465,10 +502,10 @@ export interface paths { /** 상세 조회 */ get: operations['getProblemSet']; /** 수정 */ - put: operations['update_4']; + put: operations['update_5']; post?: never; /** 삭제 */ - delete: operations['delete_2']; + delete: operations['delete_3']; options?: never; head?: never; patch?: never; @@ -500,10 +537,27 @@ export interface paths { }; get?: never; /** 수정 */ - put: operations['update_5']; + put: operations['update_6']; post?: never; /** 삭제 */ - delete: operations['delete_3']; + delete: operations['delete_4']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/pointing/bubble/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 버블 수정 */ + put: operations['update_7']; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; @@ -518,10 +572,28 @@ export interface paths { }; get?: never; /** 수정 */ - put: operations['update_6']; + put: operations['update_8']; post?: never; /** 삭제 */ - delete: operations['delete_4']; + delete: operations['delete_5']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/mock-exam/types/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 모의고사 타입 수정 */ + put: operations['updateType']; + post?: never; + /** 모의고사 타입 삭제 */ + delete: operations['deleteType']; options?: never; head?: never; patch?: never; @@ -537,10 +609,28 @@ export interface paths { /** 학생 진단 상세보기 */ get: operations['getById_1']; /** 수정 */ - put: operations['update_7']; + put: operations['update_9']; post?: never; /** 삭제 */ - delete: operations['delete_5']; + delete: operations['delete_6']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/daily-comments/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 데일리 코멘트 수정 */ + put: operations['update_10']; + post?: never; + /** 데일리 코멘트 삭제 */ + delete: operations['delete_7']; options?: never; head?: never; patch?: never; @@ -555,10 +645,118 @@ export interface paths { }; get?: never; /** 개념태그 수정 */ - put: operations['update_8']; + put: operations['update_11']; post?: never; /** 개념태그 삭제 */ - delete: operations['delete_6']; + delete: operations['delete_8']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept/graph/node/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 노드 수정 */ + put: operations['updateNode']; + post?: never; + /** 노드 삭제 */ + delete: operations['deleteNode']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept/graph/node-type/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 노드 타입 수정 */ + put: operations['updateNodeType']; + post?: never; + /** 노드 타입 삭제 */ + delete: operations['deleteNodeType']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept/graph/edge/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 엣지 수정 */ + put: operations['updateEdge']; + post?: never; + /** 엣지 삭제 */ + delete: operations['deleteEdge']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept/graph/edge-type/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 엣지 타입 수정 */ + put: operations['updateEdgeType']; + post?: never; + /** 엣지 타입 삭제 */ + delete: operations['deleteEdgeType']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept/graph/action-edge/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 액션 엣지 수정 */ + put: operations['updateActionEdge']; + post?: never; + /** 액션 엣지 삭제 */ + delete: operations['deleteActionEdge']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept/graph/action-edge-type/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 액션 엣지 타입 수정 */ + put: operations['updateActionEdgeType']; + post?: never; + /** 액션 엣지 타입 삭제 */ + delete: operations['deleteActionEdgeType']; options?: never; head?: never; patch?: never; @@ -706,6 +904,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/student/study/submit/bubble/question': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 버블 ? 버튼 누름 제출 */ + post: operations['recordBubbleQuestion']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/study/submit/answer': { parameters: { query?: never; @@ -939,6 +1154,26 @@ export interface paths { patch?: never; trace?: never; }; + '/api/student/oauth/native': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Native OAuth 로그인 + * @description 클라이언트에서 직접 받은 OAuth 토큰으로 로그인합니다. Kakao는 access_token, Google/Apple은 id_token을 전달합니다. + */ + post: operations['nativeLogin']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/notification/read/{notificationId}': { parameters: { query?: never; @@ -973,6 +1208,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/student/mock-exam': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 모의고사 정오답/학습 고민 제출 */ + post: operations['submit_1']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/me/push/token': { parameters: { query?: never; @@ -1402,6 +1654,24 @@ export interface paths { patch?: never; trace?: never; }; + '/api/admin/role': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 역할 목록 조회 */ + get: operations['findAll']; + put?: never; + /** 역할 생성 */ + post: operations['create_4']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/admin/qna/chat': { parameters: { query?: never; @@ -1430,7 +1700,7 @@ export interface paths { get: operations['search_1']; put?: never; /** 생성 */ - post: operations['create_4']; + post: operations['create_5']; delete?: never; options?: never; head?: never; @@ -1502,7 +1772,7 @@ export interface paths { get: operations['search_3']; put?: never; /** 생성 */ - post: operations['create_5']; + post: operations['create_6']; delete?: never; options?: never; head?: never; @@ -1520,7 +1790,7 @@ export interface paths { get: operations['search_4']; put?: never; /** 생성 */ - post: operations['create_6']; + post: operations['create_7']; delete?: never; options?: never; head?: never; @@ -1574,60 +1844,222 @@ export interface paths { get: operations['getsAll_1']; put?: never; /** 생성 */ - post: operations['create_7']; + post: operations['create_8']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/fcm/test': { + '/api/admin/mock-exam/types': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** 모의고사 타입 전체 조회 */ + get: operations['getAllTypes']; put?: never; - /** 특정 토큰으로 FCM 푸시 테스트 발송 */ - post: operations['sendTestPush']; + /** 모의고사 타입 생성 */ + post: operations['createType']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/diagnosis': { + '/api/admin/menu': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 학생 진단 가져오기 */ - get: operations['gets_1']; + /** 메뉴 목록 조회 */ + get: operations['findAll_1']; put?: never; - /** 생성 */ - post: operations['create_8']; + /** 메뉴 추가 */ + post: operations['create_9']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/concept': { + '/api/admin/fcm/test': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 개념 태그 검색 */ - get: operations['search_5']; + get?: never; put?: never; - /** 개념태그 생성 */ - post: operations['create_9']; + /** 특정 토큰으로 FCM 푸시 테스트 발송 */ + post: operations['sendTestPush']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/diagnosis': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 학생 진단 가져오기 */ + get: operations['gets_1']; + put?: never; + /** 생성 */ + post: operations['create_10']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/daily-comments': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 학생별 특정 일자 데일리 코멘트 조회 */ + get: operations['getByStudentAndDate']; + put?: never; + /** 데일리 코멘트 생성 또는 수정 */ + post: operations['upsert']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 개념 태그 검색 */ + get: operations['search_5']; + put?: never; + /** 개념태그 생성 */ + post: operations['create_11']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept/graph/node': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 노드 목록 조회 */ + get: operations['getNodes']; + put?: never; + /** 노드 생성 */ + post: operations['createNode']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept/graph/node-type': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 노드 타입 목록 조회 */ + get: operations['getNodeTypes']; + put?: never; + /** 노드 타입 생성 */ + post: operations['createNodeType']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept/graph/edge': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 엣지 목록 조회 */ + get: operations['getEdges']; + put?: never; + /** 엣지 생성 */ + post: operations['createEdge']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept/graph/edge-type': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 엣지 타입 목록 조회 */ + get: operations['getEdgeTypes']; + put?: never; + /** 엣지 타입 생성 */ + post: operations['createEdgeType']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept/graph/action-edge': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 액션 엣지 목록 조회 */ + get: operations['getActionEdges']; + put?: never; + /** 액션 엣지 생성 */ + post: operations['createActionEdge']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept/graph/action-edge-type': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 액션 엣지 타입 목록 조회 */ + get: operations['getActionEdgeTypes']; + put?: never; + /** 액션 엣지 타입 생성 */ + post: operations['createActionEdgeType']; delete?: never; options?: never; head?: never; @@ -1686,6 +2118,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/admin/user/{id}/role': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** 관리자에 역할 할당 (roleId=null이면 슈퍼 관리자 전환) */ + patch: operations['assignRole']; + trace?: never; + }; '/your-redirect-url': { parameters: { query?: never; @@ -2300,6 +2749,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/student/mock-exam/current-type': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 현재 활성 모의고사 타입 조회 */ + get: operations['getCurrentType']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/diagnosis': { parameters: { query?: never; @@ -2351,6 +2817,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/student/daily-comments': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 특정 일자 데일리 코멘트 조회 */ + get: operations['getByDate']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/auth/email/exists': { parameters: { query?: never; @@ -2421,22 +2904,6 @@ export interface paths { patch?: never; trace?: never; }; - '/api/exception/throw': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations['throwException']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; '/api/common/auth/refresh': { parameters: { query?: never; @@ -2517,7 +2984,7 @@ export interface paths { put?: never; post?: never; /** 삭제 */ - delete: operations['delete_7']; + delete: operations['delete_9']; options?: never; head?: never; patch?: never; @@ -2574,6 +3041,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/admin/mock-exam': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 학생별 모의고사 정오답/학습 고민 조회 */ + get: operations['getByStudent']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/admin/fcm/status': { parameters: { query?: never; @@ -2735,7 +3219,7 @@ export interface paths { put?: never; post?: never; /** 삭제 */ - delete: operations['delete_8']; + delete: operations['delete_10']; options?: never; head?: never; patch?: never; @@ -2752,7 +3236,7 @@ export interface paths { put?: never; post?: never; /** 선생님 삭제 */ - delete: operations['delete_9']; + delete: operations['delete_11']; options?: never; head?: never; patch?: never; @@ -2974,6 +3458,34 @@ export interface components { /** @description 마케팅 알림 허용 여부 (이벤트 및 업데이트 관련 알림) */ isAllowMarketingPush?: boolean; }; + StudyHandwritingUpdateRequest: { + /** @description 필기 데이터 (Base64 인코딩) */ + data?: string; + /** @description 필기 데이터 (JSON) */ + dataJson?: string; + }; + /** @description 학습 필기 데이터 응답 */ + StudyHandwritingResp: { + /** + * Format: int64 + * @description 발행 ID + */ + publishId: number; + /** + * Format: int64 + * @description 문제 ID + */ + problemId: number; + /** @description 필기 데이터 (Base64 인코딩) */ + data?: string; + /** @description 필기 데이터 (JSON) */ + dataJson?: string; + /** + * Format: date-time + * @description 필기 데이터 수정일시 (필기 없으면 null) + */ + updatedAt?: string; + }; ScrapUpdateRequest: { /** * Format: int64 @@ -3004,6 +3516,19 @@ export interface components { name: string; category: components['schemas']['ConceptCategoryResp']; }; + PointingBubbleResp: { + /** Format: int64 */ + id: number; + /** Format: int32 */ + no: number; + contentJson: string; + extendContent?: string; + /** Format: int64 */ + actionNodeId?: number; + actionNodeName?: string; + /** @description ? 버튼 눌렀는지 여부 (학생 컨텍스트에서만 설정됨) */ + isQuestionPressed?: boolean; + }; /** @description 포인팅 목록 */ PointingResp: { /** Format: int64 */ @@ -3012,6 +3537,7 @@ export interface components { no: number; questionContent: string; commentContent: string; + bubbles?: components['schemas']['PointingBubbleResp'][]; concepts: components['schemas']['ConceptResp'][]; }; PracticeTestResp: { @@ -3173,7 +3699,9 @@ export interface components { }; ScrapHandwritingUpdateRequest: { /** @description 필기 데이터 (Base64 인코딩) */ - data: string; + data?: string; + /** @description 필기 데이터 (JSON) */ + dataJson?: string; }; /** @description 필기 데이터 응답 */ ScrapHandwritingResp: { @@ -3183,7 +3711,9 @@ export interface components { */ scrapId: number; /** @description 필기 데이터 (Base64 인코딩) */ - data: string; + data?: string; + /** @description 필기 데이터 (JSON) */ + dataJson?: string; /** * Format: date-time * @description 필기 데이터 수정일시 (필기 없으면 null) @@ -3220,6 +3750,7 @@ export interface components { targetFolderId?: number; }; ListRespScrapDetailResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['ScrapDetailResp'][]; @@ -3316,6 +3847,20 @@ export interface components { TeacherStudentAssignReq: { students: number[]; }; + AdminRoleUpdateRequest: { + name: string; + menuIds: number[]; + }; + BubbleRequest: { + /** Format: int64 */ + id?: number; + /** Format: int32 */ + no?: number; + contentJson?: string; + extendContent?: string; + /** Format: int64 */ + actionNodeId?: number; + }; PointingUpdateRequest: { /** Format: int64 */ id?: number; @@ -3323,6 +3868,7 @@ export interface components { no?: number; questionContent?: string; commentContent?: string; + bubbles?: components['schemas']['BubbleRequest'][]; concepts?: number[]; }; ProblemUpdateRequest: { @@ -3415,7 +3961,7 @@ export interface components { firstProblem: components['schemas']['ProblemMetaResp']; problems: components['schemas']['ProblemSetItemResp'][]; }; - Request: { + PracticeTestUpdateRequest: { /** Format: int32 */ year: number; /** Format: int32 */ @@ -3424,25 +3970,178 @@ export interface components { grade: number; name: string; }; - DiagnosisUpdateReq: { - content?: string; - }; - DiagnosisResp: { - /** Format: int64 */ - id: number; + PointingBubbleUpdateRequest: { + /** Format: int32 */ + no?: number; + contentJson?: string; + extendContent?: string; /** Format: int64 */ - studentId?: number; - /** Format: date-time */ - createdAt?: string; - content?: string; + actionNodeId?: number; }; - ConceptUpdateRequest: { - name: string; - /** Format: int64 */ - categoryId: number; + MockExamTypeUpdateRequest: { + displayName?: string; + /** Format: date */ + startDate?: string; + /** Format: date */ + endDate?: string; }; - ConceptCategoryUpdateRequest: { - name: string; + MockExamTypeResp: { + /** Format: int64 */ + id: number; + /** + * @description /api/student/mock-exam 제출 요청의 type에 그대로 넣는 값 + * @example 2026-06 + */ + type?: string; + /** + * @description 모의고사 타입 코드. 학생 제출 요청의 type과 동일한 값 + * @example 2026-06 + */ + code?: string; + /** + * @description 화면 표시용 모의고사 타입 이름 + * @example 2026년 6월 모의고사 + */ + displayName?: string; + /** + * Format: date + * @description 해당 타입 활성 시작일 + * @example 2026-06-01 + */ + startDate?: string; + /** + * Format: date + * @description 해당 타입 활성 종료일 + * @example 2026-06-30 + */ + endDate?: string; + }; + DiagnosisUpdateReq: { + content?: string; + }; + DiagnosisResp: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + studentId?: number; + /** Format: date-time */ + createdAt?: string; + content?: string; + }; + UpdateRequest: { + /** + * @description 코멘트 내용 + * @example 수정된 코멘트입니다. + */ + contentJson?: string; + }; + AdminSummary: { + /** Format: int64 */ + id?: number; + name?: string; + }; + DailyCommentResp: { + /** Format: int64 */ + id?: number; + author?: components['schemas']['AdminSummary']; + contentJson?: string; + /** Format: date */ + commentDate?: string; + /** Format: date-time */ + expiryAt?: string; + }; + ConceptUpdateRequest: { + name: string; + /** Format: int64 */ + categoryId: number; + }; + ConceptNodeUpdateRequest: { + name: string; + /** Format: int64 */ + nodeTypeId: number; + description?: string; + payload?: { + [key: string]: Record; + }; + }; + ConceptNodeResp: { + /** Format: int64 */ + id?: number; + name?: string; + nodeType?: components['schemas']['NodeTypeCodeResp']; + description?: string; + payload?: { + [key: string]: Record; + }; + }; + NodeTypeCodeResp: { + /** Format: int64 */ + id?: number; + code?: string; + label?: string; + description?: string; + }; + NodeTypeCodeUpdateRequest: { + code: string; + label: string; + description?: string; + }; + ConceptEdgeUpdateRequest: { + /** Format: int64 */ + fromNodeId: number; + /** Format: int64 */ + edgeTypeId: number; + /** Format: int64 */ + toNodeId: number; + }; + ConceptEdgeResp: { + /** Format: int64 */ + id?: number; + fromNode?: components['schemas']['ConceptNodeResp']; + edgeType?: components['schemas']['EdgeTypeCodeResp']; + toNode?: components['schemas']['ConceptNodeResp']; + }; + EdgeTypeCodeResp: { + /** Format: int64 */ + id?: number; + code?: string; + label?: string; + description?: string; + }; + EdgeTypeCodeUpdateRequest: { + code: string; + label: string; + description?: string; + }; + ConceptActionEdgeUpdateRequest: { + /** Format: int64 */ + actionNodeId: number; + /** Format: int64 */ + roleId: number; + /** Format: int64 */ + conceptNodeId: number; + }; + ActionEdgeTypeCodeResp: { + /** Format: int64 */ + id?: number; + code?: string; + label?: string; + description?: string; + }; + ConceptActionEdgeResp: { + /** Format: int64 */ + id?: number; + actionNode?: components['schemas']['ConceptNodeResp']; + role?: components['schemas']['ActionEdgeTypeCodeResp']; + conceptNode?: components['schemas']['ConceptNodeResp']; + }; + ActionEdgeTypeCodeUpdateRequest: { + code: string; + label: string; + description?: string; + }; + ConceptCategoryUpdateRequest: { + name: string; }; ChatCreateRequest: { /** Format: int64 */ @@ -3511,6 +4210,18 @@ export interface components { */ responseTimeMs?: number; }; + PointingBubbleQuestionRequest: { + /** Format: int64 */ + bubbleId: number; + /** + * Format: int64 + * @description 발행(숙제) ID + */ + publishId?: number; + }; + PointingBubbleQuestionResponse: { + extendContent?: string; + }; SubmissionRequest: { /** Format: int64 */ publishId: number; @@ -3720,6 +4431,80 @@ export interface components { id: number; isExist: boolean; }; + /** @description Native OAuth 로그인 요청 */ + NativeOAuthReq: { + /** + * @description OAuth 제공자 + * @example KAKAO + * @enum {string} + */ + provider: 'KAKAO' | 'GOOGLE' | 'APPLE'; + /** @description OAuth 토큰 (Kakao: access_token, Google/Apple: id_token) */ + token: string; + }; + /** @description Native OAuth 로그인 응답 */ + NativeOAuthResp: { + /** @description 로그인 성공 여부 */ + success?: boolean; + /** @description 응답 메시지 */ + message?: string; + /** @description JWT Access Token */ + accessToken?: string; + /** @description JWT Refresh Token */ + refreshToken?: string; + user?: components['schemas']['NativeOAuthResp.StudentInfo']; + }; + /** @description 학생 프로필 정보 */ + 'NativeOAuthResp.StudentInfo': { + /** + * Format: int64 + * @description 학생 ID + */ + id?: number; + /** @description 이메일 */ + email?: string; + /** @description 이름 */ + name?: string; + /** @description 닉네임 */ + nickname?: string; + /** + * Format: date + * @description 생년월일 + */ + birth?: string; + /** + * @description 성별 + * @enum {string} + */ + gender?: 'MALE' | 'FEMALE'; + /** + * @description 학년 + * @enum {string} + */ + grade?: 'ONE' | 'TWO' | 'THREE' | 'N_TIME'; + /** + * @description 선택과목 + * @enum {string} + */ + selectSubject?: 'MIJUKBUN' | 'HWAKTONG' | 'KEEHA'; + /** + * Format: int32 + * @description 수능 등급 + */ + level?: number; + /** + * @description OAuth 제공자 + * @enum {string} + */ + provider?: 'KAKAO' | 'GOOGLE' | 'APPLE'; + /** @description 최초 로그인 여부 (프로필 미완성) */ + isFirstLogin?: boolean; + /** + * Format: int64 + * @description 연결된 선생님 ID + */ + teacherId?: number; + }; NotificationResp: { /** Format: int64 */ id: number; @@ -3736,6 +4521,38 @@ export interface components { /** Format: date-time */ createdAt: string; }; + MockExamResultSubmitRequest: { + /** + * @description GET /api/student/mock-exam/current-type 응답의 type 값을 그대로 전달 + * @example 2026-06 + */ + type: string; + /** + * @description 틀린 문항 번호 목록 + * @example [ + * 1, + * 2, + * 3 + * ] + */ + incorrects: number[]; + /** + * @description 학습 고민 JSON 문자열 + * @example {"text":"문제 풀이 시간이 부족해요"} + */ + question?: string; + }; + MockExamResultResp: { + /** Format: int64 */ + id: number; + type?: string; + incorrects?: number[]; + question?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; 'StudentPushDTO.UpdateTokenRequest': { fcmToken: string; }; @@ -3940,16 +4757,10 @@ export interface components { */ occurredAt: string; /** - * @description 이벤트 추가 데이터. 이벤트 타입별 권장 필드는 API 설명을 참고하세요. - * @example { - * "screenName": "Problem", - * "dwellTimeMs": 120000, - * "exitReason": "navigation" - * } + * @description 이벤트 추가 데이터 JSON 문자열. 이벤트 타입별 권장 필드는 API 설명을 참고하세요. + * @example {"screenName":"Problem","dwellTimeMs":120000,"exitReason":"navigation"} */ - metadata?: { - [key: string]: Record; - }; + metadata?: string; /** @description 클라이언트에서 생성한 이벤트 고유 ID (중복 방지용) */ clientEventId?: string; }; @@ -3966,6 +4777,10 @@ export interface components { /** Format: int32 */ count?: number; }; + AdminRoleCreateRequest: { + name: string; + menuIds: number[]; + }; PublishCreateRequest: { /** Format: int64 */ problemSetId: number; @@ -4026,6 +4841,7 @@ export interface components { no: number; questionContent: string; commentContent: string; + bubbles?: components['schemas']['PointingBubbleResp'][]; concepts: components['schemas']['ConceptResp'][]; isQuestionUnderstood?: boolean; isCommentUnderstood?: boolean; @@ -4167,6 +4983,7 @@ export interface components { no?: number; questionContent?: string; commentContent?: string; + bubbles?: components['schemas']['BubbleRequest'][]; concepts?: number[]; }; ProblemCreateRequest: { @@ -4255,6 +5072,22 @@ export interface components { /** @description FCM 지원 여부 */ fcmSupported: boolean; }; + MockExamTypeCreateRequest: { + code: string; + displayName: string; + /** Format: date */ + startDate: string; + /** Format: date */ + endDate: string; + }; + AdminMenuCreateRequest: { + name: string; + /** + * Format: int64 + * @description 상위 메뉴 ID. null이면 최상위 메뉴로 생성 + */ + parentId?: number; + }; /** @description FCM 테스트 발송 요청 */ FcmTestReq: { /** @@ -4292,25 +5125,110 @@ export interface components { studentId?: number; content?: string; }; + UpsertRequest: { + /** + * @description 학생 ID 목록 + * @example [ + * 1, + * 2, + * 3 + * ] + */ + studentIds?: number[]; + /** + * Format: date + * @description 코멘트 기준 일자 + * @example 2026-05-05 + */ + commentDate?: string; + /** + * @description 코멘트 내용 + * @example 오늘도 학습을 잘 진행했습니다. + */ + contentJson?: string; + }; ConceptCreateRequest: { name: string; /** Format: int64 */ categoryId: number; }; + ConceptNodeCreateRequest: { + name: string; + /** Format: int64 */ + nodeTypeId: number; + description?: string; + payload?: { + [key: string]: Record; + }; + }; + NodeTypeCodeCreateRequest: { + code: string; + label: string; + description?: string; + }; + ConceptEdgeCreateRequest: { + /** Format: int64 */ + fromNodeId: number; + /** Format: int64 */ + edgeTypeId: number; + /** Format: int64 */ + toNodeId: number; + }; + EdgeTypeCodeCreateRequest: { + code: string; + label: string; + description?: string; + }; + ConceptActionEdgeCreateRequest: { + /** Format: int64 */ + actionNodeId: number; + /** Format: int64 */ + roleId: number; + /** Format: int64 */ + conceptNodeId: number; + }; + ActionEdgeTypeCodeCreateRequest: { + code: string; + label: string; + description?: string; + }; ConceptCategoryCreateRequest: { name: string; }; + /** @description 접근 가능 메뉴 목록 */ + AdminMenuResp: { + /** Format: int64 */ + id?: number; + name?: string; + /** + * Format: int64 + * @description 상위 메뉴 ID. null이면 최상위 메뉴 + */ + parentId?: number; + }; AdminTokenResp: { /** Format: int64 */ id: number; email: string; + /** @enum {string} */ + adminType: 'SUPER' | 'ROLE_BASED'; token: components['schemas']['JwtResp']; + /** @description 접근 가능 메뉴 목록 */ + accessibleMenus?: components['schemas']['AdminMenuResp'][]; }; AdminLoginReq: { email: string; password: string; }; + AdminRoleAssignRequest: { + /** + * Format: int64 + * @description 역할 ID. null이면 역할 해제 (슈퍼 관리자 전환) + */ + roleId?: number; + }; ListRespPublishResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['PublishResp'][]; @@ -4320,11 +5238,13 @@ export interface components { progress: number; }; ListRespStudentResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['StudentResp'][]; }; PageRespNotListQnAGroupByWeekResp: { + requestId: string; /** Format: int32 */ page: number; /** Format: int32 */ @@ -4368,6 +5288,7 @@ export interface components { matchedChatPreview?: string; }; PageRespNotListListChatSearchResultResp: { + requestId: string; /** Format: int32 */ page: number; /** Format: int32 */ @@ -4381,11 +5302,13 @@ export interface components { chatResults?: components['schemas']['PageRespNotListListChatSearchResultResp']; }; ListRespNoticeResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['NoticeResp'][]; }; ListRespProblemEntireResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['ProblemEntireResp'][]; @@ -4429,6 +5352,7 @@ export interface components { oneStepMoreContent: string; }; ListRespPointingEntireResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['PointingEntireResp'][]; @@ -4447,12 +5371,14 @@ export interface components { no: number; questionContent: string; commentContent: string; + bubbles?: components['schemas']['PointingBubbleResp'][]; concepts: components['schemas']['ConceptResp'][]; isQuestionUnderstood?: boolean; isCommentUnderstood?: boolean; isScrapped?: boolean; }; ListRespTrashItemResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['TrashItemResp'][]; @@ -4533,26 +5459,31 @@ export interface components { scraps: components['schemas']['ScrapListItemResp'][]; }; ListRespScrapFolderResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['ScrapFolderResp'][]; }; ListRespScrapListItemResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['ScrapListItemResp'][]; }; ListRespSchoolResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['SchoolResp'][]; }; ListRespUploadFileResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['UploadFileResp'][]; }; ListRespQnaFileResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['QnaFileResp'][]; @@ -4568,6 +5499,7 @@ export interface components { isScrapped: boolean; }; ListRespNotificationResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['NotificationResp'][]; @@ -4586,6 +5518,7 @@ export interface components { latestNotification?: components['schemas']['NotificationResp']; }; PageRespNoticeResp: { + requestId: string; /** Format: int32 */ page: number; /** Format: int32 */ @@ -4602,6 +5535,7 @@ export interface components { latestNotice?: components['schemas']['NoticeResp']; }; ListRespDiagnosisResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['DiagnosisResp'][]; @@ -4719,6 +5653,7 @@ export interface components { timestamp?: string; }; PageRespTeacherResp: { + requestId: string; /** Format: int32 */ page: number; /** Format: int32 */ @@ -4728,6 +5663,7 @@ export interface components { data: components['schemas']['TeacherResp'][]; }; PageRespStudentResp: { + requestId: string; /** Format: int32 */ page: number; /** Format: int32 */ @@ -4736,7 +5672,14 @@ export interface components { lastPage: number; data: components['schemas']['StudentResp'][]; }; + AdminRoleResp: { + /** Format: int64 */ + id?: number; + name?: string; + menus?: components['schemas']['AdminMenuResp'][]; + }; PageRespProblemMetaResp: { + requestId: string; /** Format: int32 */ page: number; /** Format: int32 */ @@ -4746,6 +5689,7 @@ export interface components { data: components['schemas']['ProblemMetaResp'][]; }; ListRespProblemInfoResp: { + requestId: string; /** Format: int32 */ total: number; data: components['schemas']['ProblemInfoResp'][]; @@ -4754,6 +5698,7 @@ export interface components { customId?: string; }; PageRespProblemSetResp: { + requestId: string; /** Format: int32 */ page: number; /** Format: int32 */ @@ -4763,6 +5708,7 @@ export interface components { data: components['schemas']['ProblemSetResp'][]; }; PageRespPracticeTestResp: { + requestId: string; /** Format: int32 */ page: number; /** Format: int32 */ @@ -4771,12 +5717,25 @@ export interface components { lastPage: number; data: components['schemas']['PracticeTestResp'][]; }; + ListRespMockExamResultResp: { + requestId: string; + /** Format: int32 */ + total: number; + data: components['schemas']['MockExamResultResp'][]; + }; + ListRespMockExamTypeResp: { + requestId: string; + /** Format: int32 */ + total: number; + data: components['schemas']['MockExamTypeResp'][]; + }; /** @description FCM 상태 응답 */ FcmStatusResp: { /** @description Firebase 초기화 상태 */ initialized?: boolean; }; PageRespConceptResp: { + requestId: string; /** Format: int32 */ page: number; /** Format: int32 */ @@ -4785,7 +5744,44 @@ export interface components { lastPage: number; data: components['schemas']['ConceptResp'][]; }; + ListRespConceptNodeResp: { + requestId: string; + /** Format: int32 */ + total: number; + data: components['schemas']['ConceptNodeResp'][]; + }; + ListRespNodeTypeCodeResp: { + requestId: string; + /** Format: int32 */ + total: number; + data: components['schemas']['NodeTypeCodeResp'][]; + }; + ListRespConceptEdgeResp: { + requestId: string; + /** Format: int32 */ + total: number; + data: components['schemas']['ConceptEdgeResp'][]; + }; + ListRespEdgeTypeCodeResp: { + requestId: string; + /** Format: int32 */ + total: number; + data: components['schemas']['EdgeTypeCodeResp'][]; + }; + ListRespConceptActionEdgeResp: { + requestId: string; + /** Format: int32 */ + total: number; + data: components['schemas']['ConceptActionEdgeResp'][]; + }; + ListRespActionEdgeTypeCodeResp: { + requestId: string; + /** Format: int32 */ + total: number; + data: components['schemas']['ActionEdgeTypeCodeResp'][]; + }; PageRespConceptCategoryResp: { + requestId: string; /** Format: int32 */ page: number; /** Format: int32 */ @@ -5023,8 +6019,7 @@ export interface components { path?: string; /** Format: date-time */ timestamp?: string; - rootCause?: string; - stackTrace?: string[]; + requestId?: string; }; }; responses: never; @@ -5199,6 +6194,77 @@ export interface operations { }; }; }; + getHandwriting: { + parameters: { + query?: never; + header?: never; + path: { + publishId: number; + problemId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['StudyHandwritingResp']; + }; + }; + }; + }; + updateHandwriting: { + parameters: { + query?: never; + header?: never; + path: { + publishId: number; + problemId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['StudyHandwritingUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['StudyHandwritingResp']; + }; + }; + }; + }; + deleteHandwriting: { + parameters: { + query?: never; + header?: never; + path: { + publishId: number; + problemId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; updateScrap: { parameters: { query?: never; @@ -5303,7 +6369,7 @@ export interface operations { }; }; }; - getHandwriting: { + getHandwriting_1: { parameters: { query?: never; header?: never; @@ -5325,7 +6391,7 @@ export interface operations { }; }; }; - updateHandwriting: { + updateHandwriting_1: { parameters: { query?: never; header?: never; @@ -5351,7 +6417,7 @@ export interface operations { }; }; }; - deleteHandwriting: { + deleteHandwriting_1: { parameters: { query?: never; header?: never; @@ -5821,6 +6887,50 @@ export interface operations { }; }; }; + update_4: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['AdminRoleUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete_2: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; updateStatus_2: { parameters: { query?: never; @@ -5985,7 +7095,7 @@ export interface operations { }; }; }; - update_4: { + update_5: { parameters: { query?: never; header?: never; @@ -6011,7 +7121,7 @@ export interface operations { }; }; }; - delete_2: { + delete_3: { parameters: { query?: never; header?: never; @@ -6053,7 +7163,7 @@ export interface operations { }; }; }; - update_5: { + update_6: { parameters: { query?: never; header?: never; @@ -6064,7 +7174,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['Request']; + 'application/json': components['schemas']['PracticeTestUpdateRequest']; }; }; responses: { @@ -6079,7 +7189,7 @@ export interface operations { }; }; }; - delete_3: { + delete_4: { parameters: { query?: never; header?: never; @@ -6099,7 +7209,33 @@ export interface operations { }; }; }; - update_6: { + update_7: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PointingBubbleUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['PointingBubbleResp']; + }; + }; + }; + }; + update_8: { parameters: { query?: never; header?: never; @@ -6125,7 +7261,53 @@ export interface operations { }; }; }; - delete_4: { + delete_5: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateType: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['MockExamTypeUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['MockExamTypeResp']; + }; + }; + }; + }; + deleteType: { parameters: { query?: never; header?: never; @@ -6167,7 +7349,7 @@ export interface operations { }; }; }; - update_7: { + update_9: { parameters: { query?: never; header?: never; @@ -6193,7 +7375,7 @@ export interface operations { }; }; }; - delete_5: { + delete_6: { parameters: { query?: never; header?: never; @@ -6213,18 +7395,18 @@ export interface operations { }; }; }; - update_8: { + update_10: { parameters: { query?: never; header?: never; path: { - conceptId: number; + id: number; }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ConceptUpdateRequest']; + 'application/json': components['schemas']['UpdateRequest']; }; }; responses: { @@ -6234,52 +7416,43 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ConceptResp']; + '*/*': components['schemas']['DailyCommentResp']; }; }; }; }; - delete_6: { + delete_7: { parameters: { query?: never; header?: never; path: { - conceptId: number; + id: number; }; cookie?: never; }; requestBody?: never; responses: { - /** @description 삭제 성공 */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description 사용 중인 개념태그는 삭제할 수 없음 */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - '*/*': components['schemas']['ErrorResp']; - }; - }; }; }; - updateCategory: { + update_11: { parameters: { query?: never; header?: never; path: { - categoryId: number; + conceptId: number; }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ConceptCategoryUpdateRequest']; + 'application/json': components['schemas']['ConceptUpdateRequest']; }; }; responses: { @@ -6289,17 +7462,17 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ConceptCategoryResp']; + '*/*': components['schemas']['ConceptResp']; }; }; }; }; - deleteCategory: { + delete_8: { parameters: { query?: never; header?: never; path: { - categoryId: number; + conceptId: number; }; cookie?: never; }; @@ -6312,7 +7485,7 @@ export interface operations { }; content?: never; }; - /** @description 사용 중인 대분류는 삭제할 수 없음 */ + /** @description 사용 중인 개념태그는 삭제할 수 없음 */ 400: { headers: { [name: string]: unknown; @@ -6323,16 +7496,18 @@ export interface operations { }; }; }; - addChat: { + updateNode: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: number; + }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ChatCreateRequest']; + 'application/json': components['schemas']['ConceptNodeUpdateRequest']; }; }; responses: { @@ -6342,18 +7517,18 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['ConceptNodeResp']; }; }; }; }; - getsAll: { + deleteNode: { parameters: { - query: { - studentId: number; - }; + query?: never; header?: never; - path?: never; + path: { + id: number; + }; cookie?: never; }; requestBody?: never; @@ -6363,22 +7538,22 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['ListRespNoticeResp']; - }; + content?: never; }; }; }; - create: { + updateNodeType: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: number; + }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['NoticeCreateRequest']; + 'application/json': components['schemas']['NodeTypeCodeUpdateRequest']; }; }; responses: { @@ -6388,21 +7563,43 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['NoticeResp']; + '*/*': components['schemas']['NodeTypeCodeResp']; }; }; }; }; - updatePushToken: { + deleteNodeType: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateEdge: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['TeacherPushDTO.UpdateTokenRequest']; + 'application/json': components['schemas']['ConceptEdgeUpdateRequest']; }; }; responses: { @@ -6412,16 +7609,18 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['TeacherResp']; + '*/*': components['schemas']['ConceptEdgeResp']; }; }; }; }; - toggleAllowPush: { + deleteEdge: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: number; + }; cookie?: never; }; requestBody?: never; @@ -6431,22 +7630,22 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['TeacherResp']; - }; + content?: never; }; }; }; - refresh: { + updateEdgeType: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: number; + }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['RefreshReq']; + 'application/json': components['schemas']['EdgeTypeCodeUpdateRequest']; }; }; responses: { @@ -6456,21 +7655,43 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['TeacherTokenResp']; + '*/*': components['schemas']['EdgeTypeCodeResp']; }; }; }; }; - login: { + deleteEdgeType: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateActionEdge: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['TeacherLoginReq']; + 'application/json': components['schemas']['ConceptActionEdgeUpdateRequest']; }; }; responses: { @@ -6480,23 +7701,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['TeacherTokenResp']; + '*/*': components['schemas']['ConceptActionEdgeResp']; }; }; }; }; - feedback: { + deleteActionEdge: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['PointingFeedbackRequest']; + path: { + id: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -6507,16 +7726,18 @@ export interface operations { }; }; }; - submit: { + updateActionEdgeType: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: number; + }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['SubmissionRequest']; + 'application/json': components['schemas']['ActionEdgeTypeCodeUpdateRequest']; }; }; responses: { @@ -6526,21 +7747,43 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['SubmissionResp']; + '*/*': components['schemas']['ActionEdgeTypeCodeResp']; }; }; }; }; - createScrap: { + deleteActionEdgeType: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateCategory: { + parameters: { + query?: never; + header?: never; + path: { + categoryId: number; + }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ScrapCreateRequest']; + 'application/json': components['schemas']['ConceptCategoryUpdateRequest']; }; }; responses: { @@ -6550,34 +7793,41 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ScrapDetailResp']; + '*/*': components['schemas']['ConceptCategoryResp']; }; }; }; }; - deleteScraps: { + deleteCategory: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['ScrapBatchDeleteRequest']; + path: { + categoryId: number; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description OK */ + /** @description 삭제 성공 */ 200: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description 사용 중인 대분류는 삭제할 수 없음 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ErrorResp']; + }; + }; }; }; - toggleScrapFromReadingTip: { + addChat: { parameters: { query?: never; header?: never; @@ -6586,7 +7836,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ScrapFromReadingTipCreateRequest']; + 'application/json': components['schemas']['ChatCreateRequest']; }; }; responses: { @@ -6596,23 +7846,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ScrapToggleResp']; + '*/*': components['schemas']['QnAResp']; }; }; }; }; - toggleScrapFromProblem: { + getsAll: { parameters: { - query?: never; + query: { + studentId: number; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['ScrapFromProblemCreateRequest']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -6620,12 +7868,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ScrapToggleResp']; + '*/*': components['schemas']['ListRespNoticeResp']; }; }; }; }; - toggleScrapFromPointing: { + create: { parameters: { query?: never; header?: never; @@ -6634,7 +7882,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ScrapFromPointingCreateRequest']; + 'application/json': components['schemas']['NoticeCreateRequest']; }; }; responses: { @@ -6644,12 +7892,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ScrapToggleResp']; + '*/*': components['schemas']['NoticeResp']; }; }; }; }; - toggleScrapFromOneStepMore: { + updatePushToken: { parameters: { query?: never; header?: never; @@ -6658,7 +7906,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ScrapFromOneStepMoreCreateRequest']; + 'application/json': components['schemas']['TeacherPushDTO.UpdateTokenRequest']; }; }; responses: { @@ -6668,23 +7916,19 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ScrapToggleResp']; + '*/*': components['schemas']['TeacherResp']; }; }; }; }; - createScrapFromProblem: { + toggleAllowPush: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['ScrapFromProblemCreateRequest']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -6692,12 +7936,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ScrapDetailResp']; + '*/*': components['schemas']['TeacherResp']; }; }; }; }; - unscrapFromProblem: { + refresh: { parameters: { query?: never; header?: never; @@ -6706,7 +7950,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['UnscrapFromProblemRequest']; + 'application/json': components['schemas']['RefreshReq']; }; }; responses: { @@ -6715,11 +7959,13 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['TeacherTokenResp']; + }; }; }; }; - createScrapFromPointing: { + login: { parameters: { query?: never; header?: never; @@ -6728,7 +7974,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ScrapFromPointingCreateRequest']; + 'application/json': components['schemas']['TeacherLoginReq']; }; }; responses: { @@ -6738,12 +7984,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ScrapDetailResp']; + '*/*': components['schemas']['TeacherTokenResp']; }; }; }; }; - unscrapFromPointing: { + feedback: { parameters: { query?: never; header?: never; @@ -6752,7 +7998,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['UnscrapFromPointingRequest']; + 'application/json': components['schemas']['PointingFeedbackRequest']; }; }; responses: { @@ -6765,7 +8011,7 @@ export interface operations { }; }; }; - createScrapFromImage: { + recordBubbleQuestion: { parameters: { query?: never; header?: never; @@ -6774,7 +8020,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ScrapFromImageCreateRequest']; + 'application/json': components['schemas']['PointingBubbleQuestionRequest']; }; }; responses: { @@ -6784,19 +8030,23 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ScrapDetailResp']; + '*/*': components['schemas']['PointingBubbleQuestionResponse']; }; }; }; }; - getFolders: { + submit: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['SubmissionRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -6804,12 +8054,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespScrapFolderResp']; + '*/*': components['schemas']['SubmissionResp']; }; }; }; }; - createFolder: { + createScrap: { parameters: { query?: never; header?: never; @@ -6818,7 +8068,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ScrapFolderCreateRequest']; + 'application/json': components['schemas']['ScrapCreateRequest']; }; }; responses: { @@ -6828,12 +8078,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ScrapFolderResp']; + '*/*': components['schemas']['ScrapDetailResp']; }; }; }; }; - deleteFolders: { + deleteScraps: { parameters: { query?: never; header?: never; @@ -6842,7 +8092,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': number[]; + 'application/json': components['schemas']['ScrapBatchDeleteRequest']; }; }; responses: { @@ -6855,16 +8105,18 @@ export interface operations { }; }; }; - gets: { + toggleScrapFromReadingTip: { parameters: { - query?: { - query?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFromReadingTipCreateRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -6872,12 +8124,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespNotListQnAGroupByWeekResp']; + '*/*': components['schemas']['ScrapToggleResp']; }; }; }; }; - create_1: { + toggleScrapFromProblem: { parameters: { query?: never; header?: never; @@ -6886,7 +8138,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['QnACreateRequest']; + 'application/json': components['schemas']['ScrapFromProblemCreateRequest']; }; }; responses: { @@ -6896,12 +8148,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['ScrapToggleResp']; }; }; }; }; - checkExists: { + toggleScrapFromPointing: { parameters: { query?: never; header?: never; @@ -6910,7 +8162,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['QnACheckRequest']; + 'application/json': components['schemas']['ScrapFromPointingCreateRequest']; }; }; responses: { @@ -6920,12 +8172,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnACheckResp']; + '*/*': components['schemas']['ScrapToggleResp']; }; }; }; }; - addChat_1: { + toggleScrapFromOneStepMore: { parameters: { query?: never; header?: never; @@ -6934,7 +8186,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ChatCreateRequest']; + 'application/json': components['schemas']['ScrapFromOneStepMoreCreateRequest']; }; }; responses: { @@ -6944,21 +8196,23 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['ScrapToggleResp']; }; }; }; }; - readNotification: { + createScrapFromProblem: { parameters: { query?: never; header?: never; - path: { - notificationId: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFromProblemCreateRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -6966,19 +8220,23 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['NotificationResp']; + '*/*': components['schemas']['ScrapDetailResp']; }; }; }; }; - readAllNotifications: { + unscrapFromProblem: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['UnscrapFromProblemRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -6989,7 +8247,7 @@ export interface operations { }; }; }; - updatePushToken_1: { + createScrapFromPointing: { parameters: { query?: never; header?: never; @@ -6998,7 +8256,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['StudentPushDTO.UpdateTokenRequest']; + 'application/json': components['schemas']['ScrapFromPointingCreateRequest']; }; }; responses: { @@ -7008,12 +8266,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentResp']; + '*/*': components['schemas']['ScrapDetailResp']; }; }; }; }; - initPushSettings: { + unscrapFromPointing: { parameters: { query?: never; header?: never; @@ -7022,7 +8280,29 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['StudentPushDTO.InitPushRequest']; + 'application/json': components['schemas']['UnscrapFromPointingRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createScrapFromImage: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFromImageCreateRequest']; }; }; responses: { @@ -7032,12 +8312,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentPushDTO.SettingsResponse']; + '*/*': components['schemas']['ScrapDetailResp']; }; }; }; }; - toggleAllowPush_1: { + getFolders: { parameters: { query?: never; header?: never; @@ -7052,12 +8332,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentResp']; + '*/*': components['schemas']['ListRespScrapFolderResp']; }; }; }; }; - changePassword: { + createFolder: { parameters: { query?: never; header?: never; @@ -7066,7 +8346,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['StudentPasswordDTO.UpdatePasswordRequest']; + 'application/json': components['schemas']['ScrapFolderCreateRequest']; }; }; responses: { @@ -7076,12 +8356,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentResp']; + '*/*': components['schemas']['ScrapFolderResp']; }; }; }; }; - submitFeedback: { + deleteFolders: { parameters: { query?: never; header?: never; @@ -7090,12 +8370,12 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['FeedbackDTO.Request']; + 'application/json': number[]; }; }; responses: { - /** @description Created */ - 201: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; @@ -7103,18 +8383,16 @@ export interface operations { }; }; }; - signup: { + gets: { parameters: { - query?: never; + query?: { + query?: string; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['StudentSignupReq']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -7122,12 +8400,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentTokenResp']; + '*/*': components['schemas']['PageRespNotListQnAGroupByWeekResp']; }; }; }; }; - register: { + create_1: { parameters: { query?: never; header?: never; @@ -7136,7 +8414,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['StudentInitialRegisterDTO.Req']; + 'application/json': components['schemas']['QnACreateRequest']; }; }; responses: { @@ -7146,12 +8424,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentResp']; + '*/*': components['schemas']['QnAResp']; }; }; }; }; - refresh_1: { + checkExists: { parameters: { query?: never; header?: never; @@ -7160,7 +8438,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['RefreshReq']; + 'application/json': components['schemas']['QnACheckRequest']; }; }; responses: { @@ -7170,12 +8448,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentTokenResp']; + '*/*': components['schemas']['QnACheckResp']; }; }; }; }; - quit: { + addChat_1: { parameters: { query?: never; header?: never; @@ -7184,7 +8462,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['WithdrawDTO.Request']; + 'application/json': components['schemas']['ChatCreateRequest']; }; }; responses: { @@ -7193,11 +8471,13 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['QnAResp']; + }; }; }; }; - resetPassword: { + nativeLogin: { parameters: { query?: never; header?: never; @@ -7206,31 +8486,49 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['PasswordResetDTO.ResetPasswordRequest']; + 'application/json': components['schemas']['NativeOAuthReq']; }; }; responses: { - /** @description OK */ + /** @description 로그인 성공 */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['NativeOAuthResp']; + }; + }; + /** @description 유효하지 않은 토큰 (OAUTH_001) 또는 유효성 검증 실패 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['NativeOAuthResp']; + }; + }; + /** @description OAuth 제공자 API 오류 (OAUTH_002) */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['NativeOAuthResp']; + }; }; }; }; - verifyPasswordResetCode: { + readNotification: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['PasswordResetDTO.VerifyCodeRequest']; + path: { + notificationId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -7238,23 +8536,19 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['BooleanResp']; + '*/*': components['schemas']['NotificationResp']; }; }; }; }; - sendPasswordResetCode: { + readAllNotifications: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['PasswordResetDTO.SendCodeRequest']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -7265,7 +8559,7 @@ export interface operations { }; }; }; - getSocialLoginUrl: { + submit_1: { parameters: { query?: never; header?: never; @@ -7274,7 +8568,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['SocialLoginReq']; + 'application/json': components['schemas']['MockExamResultSubmitRequest']; }; }; responses: { @@ -7284,12 +8578,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['SocialLoginUrlResp']; + '*/*': components['schemas']['MockExamResultResp']; }; }; }; }; - login_1: { + updatePushToken_1: { parameters: { query?: never; header?: never; @@ -7298,7 +8592,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['StudentLoginReq']; + 'application/json': components['schemas']['StudentPushDTO.UpdateTokenRequest']; }; }; responses: { @@ -7308,12 +8602,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentTokenResp']; + '*/*': components['schemas']['StudentResp']; }; }; }; }; - getPreSignedUrl: { + initPushSettings: { parameters: { query?: never; header?: never; @@ -7322,7 +8616,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['PreSignedReq']; + 'application/json': components['schemas']['StudentPushDTO.InitPushRequest']; }; }; responses: { @@ -7332,23 +8626,19 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PreSignedResp']; + '*/*': components['schemas']['StudentPushDTO.SettingsResponse']; }; }; }; }; - verify: { + toggleAllowPush_1: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['PhoneAuthVerifyRequest']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -7356,12 +8646,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['SimpleSuccessResp']; + '*/*': components['schemas']['StudentResp']; }; }; }; }; - send: { + changePassword: { parameters: { query?: never; header?: never; @@ -7370,7 +8660,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['PhoneAuthSendRequest']; + 'application/json': components['schemas']['StudentPasswordDTO.UpdatePasswordRequest']; }; }; responses: { @@ -7380,12 +8670,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['SimpleSuccessResp']; + '*/*': components['schemas']['StudentResp']; }; }; }; }; - resend: { + submitFeedback: { parameters: { query?: never; header?: never; @@ -7394,22 +8684,20 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['PhoneAuthResendRequest']; + 'application/json': components['schemas']['FeedbackDTO.Request']; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['SimpleSuccessResp']; - }; + content?: never; }; }; }; - collectEvents: { + signup: { parameters: { query?: never; header?: never; @@ -7418,27 +8706,22 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['UserEventBatchRequest']; + 'application/json': components['schemas']['StudentSignupReq']; }; }; responses: { - /** @description 이벤트 저장 성공 */ - 204: { + /** @description OK */ + 200: { headers: { [name: string]: unknown; }; - content?: never; - }; - /** @description 잘못된 요청 (검증 실패) */ - 400: { - headers: { - [name: string]: unknown; + content: { + '*/*': components['schemas']['StudentTokenResp']; }; - content?: never; }; }; }; - create_2: { + register: { parameters: { query?: never; header?: never; @@ -7447,7 +8730,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['AdminCreateRequest']; + 'application/json': components['schemas']['StudentInitialRegisterDTO.Req']; }; }; responses: { @@ -7456,22 +8739,24 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['StudentResp']; + }; }; }; }; - search: { + refresh_1: { parameters: { - query?: { - query?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['RefreshReq']; + }; + }; responses: { /** @description OK */ 200: { @@ -7479,12 +8764,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespTeacherResp']; + '*/*': components['schemas']['StudentTokenResp']; }; }; }; }; - create_3: { + quit: { parameters: { query?: never; header?: never; @@ -7493,7 +8778,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['TeacherCreateRequest']; + 'application/json': components['schemas']['WithdrawDTO.Request']; }; }; responses: { @@ -7502,25 +8787,20 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['TeacherResp']; - }; + content?: never; }; }; }; - batch: { + resetPassword: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - 'multipart/form-data': { - /** Format: binary */ - file: string; - }; + 'application/json': components['schemas']['PasswordResetDTO.ResetPasswordRequest']; }; }; responses: { @@ -7529,13 +8809,11 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['SchoolSaveRespDTO']; - }; + content?: never; }; }; }; - addChat_2: { + verifyPasswordResetCode: { parameters: { query?: never; header?: never; @@ -7544,7 +8822,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ChatCreateRequest']; + 'application/json': components['schemas']['PasswordResetDTO.VerifyCodeRequest']; }; }; responses: { @@ -7554,7 +8832,365 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['BooleanResp']; + }; + }; + }; + }; + sendPasswordResetCode: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PasswordResetDTO.SendCodeRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getSocialLoginUrl: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['SocialLoginReq']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['SocialLoginUrlResp']; + }; + }; + }; + }; + login_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['StudentLoginReq']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['StudentTokenResp']; + }; + }; + }; + }; + getPreSignedUrl: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PreSignedReq']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['PreSignedResp']; + }; + }; + }; + }; + verify: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PhoneAuthVerifyRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['SimpleSuccessResp']; + }; + }; + }; + }; + send: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PhoneAuthSendRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['SimpleSuccessResp']; + }; + }; + }; + }; + resend: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PhoneAuthResendRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['SimpleSuccessResp']; + }; + }; + }; + }; + collectEvents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UserEventBatchRequest']; + }; + }; + responses: { + /** @description 이벤트 저장 성공 */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description 잘못된 요청 (검증 실패) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + create_2: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['AdminCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + search: { + parameters: { + query?: { + query?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['PageRespTeacherResp']; + }; + }; + }; + }; + create_3: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['TeacherCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['TeacherResp']; + }; + }; + }; + }; + batch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'multipart/form-data': { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['SchoolSaveRespDTO']; + }; + }; + }; + }; + findAll: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['AdminRoleResp'][]; + }; + }; + }; + }; + create_4: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['AdminRoleCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + addChat_2: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ChatCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; }; }; }; @@ -7562,9 +9198,203 @@ export interface operations { search_1: { parameters: { query?: { - year?: number; - month?: number; - studentId?: number; + year?: number; + month?: number; + studentId?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespPublishResp']; + }; + }; + }; + }; + create_5: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PublishCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['PublishResp']; + }; + }; + }; + }; + search_2: { + parameters: { + query?: { + customId?: string; + title?: string; + concepts?: number[]; + problemType?: 'MAIN_PROBLEM' | 'CHILD_PROBLEM'; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['PageRespProblemMetaResp']; + }; + }; + }; + }; + createProblem: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ProblemCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ProblemInfoResp']; + }; + }; + }; + }; + getChildren: { + parameters: { + query?: never; + header?: never; + path: { + parentId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespProblemInfoResp']; + }; + }; + }; + }; + addChild: { + parameters: { + query?: never; + header?: never; + path: { + parentId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ProblemCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ProblemInfoResp']; + }; + }; + }; + }; + removeChildren: { + parameters: { + query: { + childIds: number[]; + }; + header?: never; + path: { + parentId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createProblemWithChild: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ProblemEntireCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ProblemInfoResp']; + }; + }; + }; + }; + search_3: { + parameters: { + query?: { + setTitle?: string; + problemTitle?: string; + page?: number; + size?: number; }; header?: never; path?: never; @@ -7578,12 +9408,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespPublishResp']; + '*/*': components['schemas']['PageRespProblemSetResp']; }; }; }; }; - create_4: { + create_6: { parameters: { query?: never; header?: never; @@ -7592,7 +9422,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['PublishCreateRequest']; + 'application/json': components['schemas']['ProblemSetCreateRequest']; }; }; responses: { @@ -7602,18 +9432,18 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PublishResp']; + '*/*': components['schemas']['ProblemSetResp']; }; }; }; }; - search_2: { + search_4: { parameters: { query?: { - customId?: string; - title?: string; - concepts?: number[]; - problemType?: 'MAIN_PROBLEM' | 'CHILD_PROBLEM'; + query?: string; + year?: number; + month?: number; + grade?: number; page?: number; size?: number; }; @@ -7629,12 +9459,216 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespProblemMetaResp']; + '*/*': components['schemas']['PageRespPracticeTestResp']; }; }; }; }; - createProblem: { + create_7: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PracticeTestCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['PracticeTestResp']; + }; + }; + }; + }; + redirect: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': string; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': string; + }; + }; + }; + }; + sendNotification: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['NotificationSendRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['NotificationSendResp']; + }; + }; + }; + }; + getsAll_1: { + parameters: { + query: { + studentId: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespNoticeResp']; + }; + }; + }; + }; + create_8: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['NoticeCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['NoticeResp']; + }; + }; + }; + }; + getAllTypes: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespMockExamTypeResp']; + }; + }; + }; + }; + createType: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['MockExamTypeCreateRequest']; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['MockExamTypeResp']; + }; + }; + }; + }; + findAll_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['AdminMenuResp'][]; + }; + }; + }; + }; + create_9: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['AdminMenuCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + sendTestPush: { parameters: { query?: never; header?: never; @@ -7643,7 +9677,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ProblemCreateRequest']; + 'application/json': components['schemas']['FcmTestReq']; }; }; responses: { @@ -7653,18 +9687,18 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemInfoResp']; + '*/*': components['schemas']['FcmTestResp']; }; }; }; }; - getChildren: { + gets_1: { parameters: { - query?: never; - header?: never; - path: { - parentId: number; + query: { + studentId: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -7675,23 +9709,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespProblemInfoResp']; + '*/*': components['schemas']['ListRespDiagnosisResp']; }; }; }; }; - addChild: { + create_10: { parameters: { query?: never; header?: never; - path: { - parentId: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ProblemCreateRequest']; + 'application/json': components['schemas']['DiagnosisCreateReq']; }; }; responses: { @@ -7701,20 +9733,27 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemInfoResp']; + '*/*': components['schemas']['DiagnosisResp']; }; }; }; }; - removeChildren: { + getByStudentAndDate: { parameters: { - query: { - childIds: number[]; + query?: { + /** + * @description 학생 ID + * @example 1 + */ + studentId?: number; + /** + * @description 조회 기준 일자 + * @example 2026-05-05 + */ + commentDate?: string; }; header?: never; - path: { - parentId: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -7724,11 +9763,13 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['DailyCommentResp'][]; + }; }; }; }; - createProblemWithChild: { + upsert: { parameters: { query?: never; header?: never; @@ -7737,7 +9778,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ProblemEntireCreateRequest']; + 'application/json': components['schemas']['UpsertRequest']; }; }; responses: { @@ -7747,16 +9788,16 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemInfoResp']; + '*/*': components['schemas']['DailyCommentResp'][]; }; }; }; }; - search_3: { + search_5: { parameters: { query?: { - setTitle?: string; - problemTitle?: string; + query?: string; + categoryId?: number; page?: number; size?: number; }; @@ -7772,12 +9813,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespProblemSetResp']; + '*/*': components['schemas']['PageRespConceptResp']; }; }; }; }; - create_5: { + create_11: { parameters: { query?: never; header?: never; @@ -7786,7 +9827,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ProblemSetCreateRequest']; + 'application/json': components['schemas']['ConceptCreateRequest']; }; }; responses: { @@ -7796,21 +9837,14 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemSetResp']; + '*/*': components['schemas']['ConceptResp']; }; }; }; }; - search_4: { + getNodes: { parameters: { - query?: { - query?: string; - year?: number; - month?: number; - grade?: number; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -7823,12 +9857,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespPracticeTestResp']; + '*/*': components['schemas']['ListRespConceptNodeResp']; }; }; }; }; - create_6: { + createNode: { parameters: { query?: never; header?: never; @@ -7837,33 +9871,29 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['PracticeTestCreateRequest']; + 'application/json': components['schemas']['ConceptNodeCreateRequest']; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PracticeTestResp']; + '*/*': components['schemas']['ConceptNodeResp']; }; }; }; }; - redirect: { + getNodeTypes: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': string; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -7871,12 +9901,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': string; + '*/*': components['schemas']['ListRespNodeTypeCodeResp']; }; }; }; }; - sendNotification: { + createNodeType: { parameters: { query?: never; header?: never; @@ -7885,26 +9915,24 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['NotificationSendRequest']; + 'application/json': components['schemas']['NodeTypeCodeCreateRequest']; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - '*/*': components['schemas']['NotificationSendResp']; + '*/*': components['schemas']['NodeTypeCodeResp']; }; }; }; }; - getsAll_1: { + getEdges: { parameters: { - query: { - studentId: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -7917,12 +9945,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespNoticeResp']; + '*/*': components['schemas']['ListRespConceptEdgeResp']; }; }; }; }; - create_7: { + createEdge: { parameters: { query?: never; header?: never; @@ -7931,9 +9959,29 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['NoticeCreateRequest']; + 'application/json': components['schemas']['ConceptEdgeCreateRequest']; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ConceptEdgeResp']; + }; }; }; + }; + getEdgeTypes: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -7941,12 +9989,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['NoticeResp']; + '*/*': components['schemas']['ListRespEdgeTypeCodeResp']; }; }; }; }; - sendTestPush: { + createEdgeType: { parameters: { query?: never; header?: never; @@ -7955,26 +10003,24 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['FcmTestReq']; + 'application/json': components['schemas']['EdgeTypeCodeCreateRequest']; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - '*/*': components['schemas']['FcmTestResp']; + '*/*': components['schemas']['EdgeTypeCodeResp']; }; }; }; }; - gets_1: { + getActionEdges: { parameters: { - query: { - studentId: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -7987,12 +10033,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespDiagnosisResp']; + '*/*': components['schemas']['ListRespConceptActionEdgeResp']; }; }; }; }; - create_8: { + createActionEdge: { parameters: { query?: never; header?: never; @@ -8001,29 +10047,24 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['DiagnosisCreateReq']; + 'application/json': components['schemas']['ConceptActionEdgeCreateRequest']; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - '*/*': components['schemas']['DiagnosisResp']; + '*/*': components['schemas']['ConceptActionEdgeResp']; }; }; }; }; - search_5: { + getActionEdgeTypes: { parameters: { - query?: { - query?: string; - categoryId?: number; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8036,12 +10077,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespConceptResp']; + '*/*': components['schemas']['ListRespActionEdgeTypeCodeResp']; }; }; }; }; - create_9: { + createActionEdgeType: { parameters: { query?: never; header?: never; @@ -8050,17 +10091,17 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ConceptCreateRequest']; + 'application/json': components['schemas']['ActionEdgeTypeCodeCreateRequest']; }; }; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ConceptResp']; + '*/*': components['schemas']['ActionEdgeTypeCodeResp']; }; }; }; @@ -8161,6 +10202,30 @@ export interface operations { }; }; }; + assignRole: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['AdminRoleAssignRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; oauthRedirectExample: { parameters: { query: { @@ -9030,6 +11095,26 @@ export interface operations { }; }; }; + getCurrentType: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['MockExamTypeResp']; + }; + }; + }; + }; gets_3: { parameters: { query?: never; @@ -9092,6 +11177,32 @@ export interface operations { }; }; }; + getByDate: { + parameters: { + query?: { + /** + * @description 조회 기준 일자 + * @example 2026-05-05 + */ + commentDate?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['DailyCommentResp'][]; + }; + }; + }; + }; existsByEmail: { parameters: { query: { @@ -9175,26 +11286,6 @@ export interface operations { }; }; }; - throwException: { - parameters: { - query?: { - message?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; refresh_3: { parameters: { query?: never; @@ -9305,7 +11396,7 @@ export interface operations { }; }; }; - delete_7: { + delete_9: { parameters: { query?: never; header?: never; @@ -9390,6 +11481,28 @@ export interface operations { }; }; }; + getByStudent: { + parameters: { + query: { + studentId: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespMockExamResultResp']; + }; + }; + }; + }; getStatus: { parameters: { query?: never; @@ -9620,7 +11733,7 @@ export interface operations { }; }; }; - delete_8: { + delete_10: { parameters: { query?: never; header?: never; @@ -9640,7 +11753,7 @@ export interface operations { }; }; }; - delete_9: { + delete_11: { parameters: { query?: never; header?: never;