File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
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가 없다. 아래 쓰기 함수는 다른 관리 화면과 같은 규약으로
* 미리 맞춰 둔 것이라 백엔드가 생기기 전에는 실패한다.
* 이슈: 학교급·학년은 컬럼이 없다. 검색 VO에 `searchSchul`/`searchGrade`가 있지만 매퍼의
* `<if>` 블록이 비어 있어 필터가 조용히 무시된다.
* 이슈: 키워드가 별도 테이블이 아니라 `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<string, unknown> {
return value !== null && typeof value === 'object';
}
function readString(source: Record<string, unknown>, 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<string, string | number> {
const keyword = query.keyword.trim();
return {
searchSchul: query.schoolLevel,
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<unknown>(`${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<QuizPage> {
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<QuizItem | null> {
const { items } = await requestList(
{
schoolLevel: '',
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,
schulGradeCd: values.schoolLevel,
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<string | null> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(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<string | null> {
return sendWrite(QUIZ_PATH, 'POST', buildQuizPayload(values));
}
export async function updateQuiz(
quizSn: string,
values: QuizFormValues
): Promise<void> {
await sendWrite(
`${QUIZ_PATH}/${encodeURIComponent(quizSn)}`,
'PUT',
buildQuizPayload(values)
);
}
export async function deleteQuiz(quizSn: string): Promise<void> {
await sendWrite(`${QUIZ_PATH}/${encodeURIComponent(quizSn)}`, 'DELETE');
}
/** 엑셀 일괄등록. 한 행이라도 검증에 실패하면 백엔드가 전체를 되돌린다. */
export async function uploadQuizExcel(file: File): Promise<void> {
const accessToken = await getSessionAccessToken();
const multipart = new FormData();
multipart.set('file', file);
const result = await backendFetch<unknown>(`${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);
}
}