import 'server-only'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import type { QuizItem } from '@/lib/domain/content'; import type { ContentQuery } from '@/lib/domain/content-query'; import type { QuizFormValues } from '@/lib/domain/content-form'; /** * 금융OX퀴즈 Repository — 쇼츠·만화와 테이블(`TB_COM_QUIZ`)도 API도 다르다. * * ``` * GET /api/v1/mngr/quiz/pagination 목록 * GET /api/v1/mngr/quiz/excel/download 엑셀 다운로드 * POST /api/v1/mngr/quiz/excel/upload 엑셀 일괄등록 (multipart, 파트 이름 `file`) * ``` * * 이슈: 등록·수정·삭제·단건조회 API가 없다. 아래 쓰기 함수는 다른 관리 화면과 같은 규약으로 * 미리 맞춰 둔 것이라 백엔드가 생기기 전에는 실패한다. * 이슈: 학교(`COM_SCHUL_CD`)·학년(`GRD_CD`)을 담을 컬럼이 없다. 검색 VO에 `searchSchul`/ * `searchGrade`가 있지만 매퍼의 `` 블록이 비어 있어 필터가 조용히 무시된다. * 이슈: 키워드가 별도 테이블이 아니라 `quiz_title` 한 칸이다. 목록의 「키워드」 열이 이 값이고, * 기획의 "키워드+설명 쌍"을 담을 자리는 아직 없다. * 이슈: 정답 노출 문구(기획 "기본 3종 + 기타")를 담을 컬럼이 없다. */ const QUIZ_PATH = '/api/v1/mngr/quiz'; /** 목록에서 단건을 찾을 때 한 번에 받아올 최대 행 수(단건 조회 API가 없다). */ const DETAIL_SCAN_LIMIT = 500; /** 엑셀 업로드는 일반 조회보다 오래 걸린다. */ const UPLOAD_TIMEOUT_MS = 60_000; function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; } function readString(source: Record, key: string): string | null { const value = source[key]; if (typeof value === 'string' && value.length > 0) return value; if (typeof value === 'number') return String(value); return null; } function toQuizItem(raw: unknown): QuizItem { if (!isRecord(raw)) { throw new Error('퀴즈 응답 항목의 형식이 올바르지 않습니다.'); } const quizSn = readString(raw, 'quizSn'); if (!quizSn) { throw new Error('퀴즈 응답에 quizSn이 없습니다.'); } const points = raw.quizPoint; return { quizSn, keywordText: readString(raw, 'quizTitle') ?? '', level: readString(raw, 'quizLev') ?? '', question: readString(raw, 'quizQuestion') ?? '', explanation: readString(raw, 'quizExplan') ?? '', answer: readString(raw, 'quizAnswer') ?? '', points: typeof points === 'number' ? points : null, isVisible: readString(raw, 'useYn') !== 'N', modifiedAt: readString(raw, 'lastMdfcnDtStr') ?? readString(raw, 'frstRegDtStr'), }; } function buildListParams( query: ContentQuery, pageIndex: number, recordCountPerPage: number ): Record { const keyword = query.keyword.trim(); return { searchSchul: query.school, searchGrade: query.grade, searchLevel: query.level, searchCondition: keyword ? '1' : '', searchKeyword: keyword, searchSortOrder: query.sort, pageIndex, recordCountPerPage, }; } async function requestList( query: ContentQuery, pageIndex: number, recordCountPerPage: number ): Promise<{ items: QuizItem[]; reportedTotalCount: number }> { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${QUIZ_PATH}/pagination`, { method: 'GET', query: buildListParams(query, pageIndex, recordCountPerPage), accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, }); if (!result.ok) { throw new BackendRequestError(result); } const data = result.data; if (data === null || data === undefined) { return { items: [], reportedTotalCount: 0 }; } if (!isRecord(data) || !Array.isArray(data.list)) { throw new Error('퀴즈 목록 응답의 형식이 올바르지 않습니다.'); } return { items: data.list.map(toQuizItem), reportedTotalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length, }; } export type QuizPage = { items: QuizItem[]; totalCount: number; isTotalCountExact: boolean; }; export async function fetchQuizzes(query: ContentQuery): Promise { const { items, reportedTotalCount } = await requestList( query, query.page, query.pageSize ); const confirmedCount = (query.page - 1) * query.pageSize + items.length; return { items, totalCount: Math.max(reportedTotalCount, confirmedCount), isTotalCountExact: items.length < query.pageSize || reportedTotalCount > confirmedCount, }; } /** TODO: `GET /api/v1/mngr/quiz/{quizSn}`이 생기면 이 스캔을 그 호출로 교체한다. */ export async function fetchQuizDetail(quizSn: string): Promise { const { items } = await requestList( { school: '', grade: '', level: '', searchField: '', keyword: '', sort: 'created', page: 1, pageSize: 10, }, 1, DETAIL_SCAN_LIMIT ); return items.find((item) => item.quizSn === quizSn) ?? null; } function buildQuizPayload(values: QuizFormValues) { return { quizTitle: values.keywords.map((item) => item.keyword).join(', '), quizLev: values.level, quizQuestion: values.question, quizExplan: values.explanation, quizAnswer: values.answer, schulCd: values.schoolCode, gradeCd: values.grades.join(','), answerMssageCd: values.answerMessageCode, answerMssage: values.answerMessageText, useYn: values.isVisible ? 'Y' : 'N', }; } async function sendWrite( path: string, method: 'POST' | 'PUT' | 'DELETE', body?: unknown ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(path, { method, ...(body === undefined ? {} : { body }), accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { throw new BackendRequestError(result); } return typeof result.data === 'string' ? result.data : null; } export async function createQuiz(values: QuizFormValues): Promise { return sendWrite(QUIZ_PATH, 'POST', buildQuizPayload(values)); } export async function updateQuiz( quizSn: string, values: QuizFormValues ): Promise { await sendWrite( `${QUIZ_PATH}/${encodeURIComponent(quizSn)}`, 'PUT', buildQuizPayload(values) ); } export async function deleteQuiz(quizSn: string): Promise { await sendWrite(`${QUIZ_PATH}/${encodeURIComponent(quizSn)}`, 'DELETE'); } /** 엑셀 일괄등록. 한 행이라도 검증에 실패하면 백엔드가 전체를 되돌린다. */ export async function uploadQuizExcel(file: File): Promise { const accessToken = await getSessionAccessToken(); const multipart = new FormData(); multipart.set('file', file); const result = await backendFetch(`${QUIZ_PATH}/excel/upload`, { method: 'POST', multipart, accessToken: accessToken ?? undefined, cache: 'no-store', timeoutMs: UPLOAD_TIMEOUT_MS, canHaveNullData: true, }); if (!result.ok) { throw new BackendRequestError(result); } }