import 'server-only'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import type { ContentKeyword, QuizAnswerText, 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_CNTNTS_QUIZ`)은 다르지만 콘텐츠관리 * API 아래로 들어와 규약은 같다. * * ``` * GET /api/v1/mngr/cntntns/quiz/pagination 학교·학년·난이도·검색어 * GET /api/v1/mngr/cntntns/quiz/{quizId} 단건 * POST /api/v1/mngr/cntntns/quiz @RequestBody(JSON) * PUT /api/v1/mngr/cntntns/quiz/{quizId} @RequestBody(JSON) * DELETE /api/v1/mngr/cntntns/quiz/{quizId} soft delete(DEL_YN) * GET /api/v1/mngr/cntntns/quiz/excel/download 엑셀 다운로드 * ``` * * 학교·학년은 `TB_COM_QUIZ_SCHUL_GRD`에 따로 저장되고 `schulGrdList`로 오간다 — 쇼츠·만화와 * 같은 모양이라 화면 쪽 취급도 같다. * * ⚠️ 백엔드 결함(보고함, 미수정) * 1. 검색어가 질문·정답만 본다(`QUIZ_ANSWER`·`QUIZ_QUESTION`). 화면의 「키워드」 검색은 * `QUIZ_TITLE`을 봐야 하는데 조건에 없어 결과가 비어 온다. * 2. 난이도 필터가 SQL에 없다 — `searchLevelCd`를 받지만 `searchWhere`가 쓰지 않는다. * 화면 선택지는 기획대로 두고 값만 실어 보낸다. */ const QUIZ_PATH = '/api/v1/mngr/cntntns/quiz'; 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; } /** `schulGrdList` → 화면이 쓰는 학교 코드 + 학년 코드 목록(쇼츠·만화와 같은 규칙). */ /** 키워드 목록 — 백엔드 `CntntsKeywordVo` 배열. 키워드가 빈 줄은 버린다. */ function toKeywords(raw: unknown): ContentKeyword[] { if (!Array.isArray(raw)) { return []; } return raw.flatMap((entry) => { if (!isRecord(entry)) { return []; } const keyword = readString(entry, 'cntntsKeyword'); if (!keyword) { return []; } return [{ keyword, description: readString(entry, 'cntntsExpln') ?? '' }]; }); } function toSchoolGrades(raw: unknown): { schoolCode: string; grades: string[] } { if (!Array.isArray(raw)) { return { schoolCode: '', grades: [] }; } const grades: string[] = []; let schoolCode = ''; for (const entry of raw) { if (!isRecord(entry)) { continue; } schoolCode = schoolCode || (readString(entry, 'schulCd') ?? ''); const grade = readString(entry, 'grdCd'); if (grade && !grades.includes(grade)) { grades.push(grade); } } return { schoolCode, grades }; } function toSchoolGradePayload(schoolCode: string, grades: readonly string[]) { if (!schoolCode) { return []; } if (grades.length === 0) { // 학년 없는 학교(전체연령 등)는 학교만 남긴다 — 빈 문자열을 넣으면 GRD_CD에 ''가 박힌다. return [{ schulCd: schoolCode, grdCd: null }]; } return grades.map((grade) => ({ schulCd: schoolCode, grdCd: grade })); } function toQuizItem(raw: unknown): QuizItem { if (!isRecord(raw)) { throw new Error('퀴즈 응답 항목의 형식이 올바르지 않습니다.'); } const quizId = readString(raw, 'cntntsId'); if (!quizId) { throw new Error('퀴즈 응답에 cntntsId가 없습니다.'); } const schulGrd = toSchoolGrades(raw.schulGrdList); return { quizId, keywords: toKeywords(raw.keywordList), level: readString(raw, 'cntntsQuizLev') ?? '', question: readString(raw, 'cntntsQuizQuestion') ?? '', explanation: readString(raw, 'cntntsQuizExplan') ?? '', answer: readString(raw, 'cntntsQuizAnswer') ?? '', schoolCode: schulGrd.schoolCode, grades: schulGrd.grades, answerMessages: toAnswerMessages(raw.descrList), isVisible: readString(raw, 'useYn') !== 'N', modifiedAt: readString(raw, 'lastMdfcnDtStr') ?? readString(raw, 'frstRegDtStr'), }; } function buildListParams( query: ContentQuery, pageIndex: number, recordCountPerPage: number ): Record { // 검색어는 보내지 않는다 — 백엔드 조건이 이름이 바뀌기 전 컬럼을 보고 있어 실으면 목록이 // SQL 오류로 죽는다. 난이도도 받기만 하고 쓰지 않아 함께 뺀다. 둘 다 화면에서 감췄다. return { searchSchulGradeCd: query.school, searchGradeCd: query.grade, 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, canHaveEmptyBody: 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, }; } export async function fetchQuizDetail(quizId: string): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch( `${QUIZ_PATH}/${encodeURIComponent(quizId)}`, { method: 'GET', accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, } ); if (!result.ok) { throw new BackendRequestError(result); } if (result.data === null || result.data === undefined) { return null; } return toQuizItem(result.data); } /** 키워드 목록의 필드명. 백엔드 `CntntsKeywordVo`와 같다(콘텐츠 API와 동일한 구조다). */ const QUIZ_KEYWORD_FIELD = { /** 목록 자체의 이름. */ list: 'keywordList', /** 항목의 키워드 필드. */ keyword: 'cntntsKeyword', /** 항목의 설명 필드. */ description: 'cntntsExpln', } as const; type QuizKeywordPayload = Record; /** * 정답별 노출 문구 — 백엔드 `descrList`(`CntntsQuizDescrVo` 배열, `TB_COM_CNTNTS_QUIZ_DESCR`). * * 칸이 하나(`CNTNTS_QUIZ_DESCR`)뿐이라 코드가 아니라 **문구 자체**를 싣는다. 학습자 앱이 * 이 값을 그대로 말풍선에 띄우기 때문이다(`CmmBotChatServiceImpl`이 조회해 응답에 넣는다). */ type QuizAnswerDescrPayload = { cntntsQuizAnswer: string; cntntsQuizDescr: string; }; /** `descrList` → 화면이 수정 폼에 되살릴 정답별 문구. */ function toAnswerMessages(raw: unknown): QuizAnswerText[] { if (!Array.isArray(raw)) { return []; } return raw.flatMap((entry) => { if (!isRecord(entry)) { return []; } const answer = readString(entry, 'cntntsQuizAnswer'); if (!answer) { return []; } return [{ answer, message: readString(entry, 'cntntsQuizDescr') ?? '' }]; }); } type QuizWritePayload = { cntntsQuizLev: string; cntntsQuizQuestion: string; cntntsQuizExplan: string; cntntsQuizAnswer: string; useYn: string; keywordList: QuizKeywordPayload[]; descrList: QuizAnswerDescrPayload[]; schulGrdList: Array<{ schulCd: string; grdCd: string | null }>; }; function buildQuizPayload(values: QuizFormValues): QuizWritePayload { return { cntntsQuizLev: values.level, cntntsQuizQuestion: values.question, cntntsQuizExplan: values.explanation, cntntsQuizAnswer: values.answer, useYn: values.isVisible ? 'Y' : 'N', keywordList: values.keywords.map((item) => ({ [QUIZ_KEYWORD_FIELD.keyword]: item.keyword, [QUIZ_KEYWORD_FIELD.description]: item.description, })), // `text`에는 검증 단계가 고른 문구를 넣어 둔다(코드를 골랐으면 그 코드명). descrList: values.answerMessages.map((message) => ({ cntntsQuizAnswer: message.answer, cntntsQuizDescr: message.text, })), schulGrdList: toSchoolGradePayload(values.schoolCode, values.grades), }; } 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); } } export async function createQuiz(values: QuizFormValues): Promise { await sendWrite(QUIZ_PATH, 'POST', buildQuizPayload(values)); } export async function updateQuiz( quizId: string, values: QuizFormValues ): Promise { await sendWrite( `${QUIZ_PATH}/${encodeURIComponent(quizId)}`, 'PUT', buildQuizPayload(values) ); } export async function deleteQuiz(quizId: string): Promise { await sendWrite(`${QUIZ_PATH}/${encodeURIComponent(quizId)}`, 'DELETE'); }