임동욱 임동욱 09-03
feat: 퀴즈 키워드를 목록으로 주고받고 포인트를 뺀다
백엔드 요청 VO에서 quizTitle·quizPoint가 사라지고 keywordList가 들어왔다.

- 키워드는 쉼표로 이은 문자열 대신 목록으로 읽고 보낸다. 항목마다 설명을 갖는데
  입력 칸은 공용 키워드 필드에 이미 있었고, 읽을 때만 유실되고 있었다(사용자 확정).
- 포인트는 저장되지 않으므로 숨김 칸과 폼 값에서 뺀다(사용자 확정). 되돌릴 문자열이
  없어진 keywordsFromText도 함께 지운다.

Co-Authored-By: Claude Opus 5 
@f9c4b752d37570c1ed9b5df6d420b31d7101efee
app/(protected)/(basic)/contents/_actions.ts
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -174,7 +174,6 @@
   return {
     schoolCode: readString(formData, 'schoolCode'),
     grades: readGrades(formData),
-    points: readString(formData, 'quizPoint'),
     level: readString(formData, 'level'),
     keywords: readKeywords(formData),
     question: readString(formData, 'question'),
app/(protected)/(basic)/contents/quizzes/_components/quiz-form.tsx
--- app/(protected)/(basic)/contents/quizzes/_components/quiz-form.tsx
+++ app/(protected)/(basic)/contents/quizzes/_components/quiz-form.tsx
@@ -14,7 +14,6 @@
   ANSWER_MESSAGE_OPTIONS,
   QUIZ_ANSWER_OPTIONS,
   QUIZ_LEVEL_OPTIONS,
-  keywordsFromText,
   type QuizItem,
   type GradesBySchool,
 } from '@/lib/domain/content';
@@ -71,7 +70,6 @@
       >
         {isEdit && <input type="hidden" name="quizId" value={item.quizId} />}
         {/* 화면에 입력 칸이 없는 값이라 그대로 돌려보내 지워지지 않게 한다(도메인 주석 참조). */}
-        <input type="hidden" name="quizPoint" value={item?.points ?? ''} />
 
         {isEdit && (
           <ContentFormRow label="콘텐츠ID">
@@ -107,7 +105,7 @@
         <ContentFormRow label="키워드">
           <ContentKeywordField
             defaultKeywords={
-              item ? keywordsFromText(item.keywordText) : undefined
+              item ? item.keywords : undefined
             }
             error={errors.keywords}
           />
app/(protected)/(basic)/contents/quizzes/_components/quizzes-list.tsx
--- app/(protected)/(basic)/contents/quizzes/_components/quizzes-list.tsx
+++ app/(protected)/(basic)/contents/quizzes/_components/quizzes-list.tsx
@@ -5,6 +5,7 @@
 import {
   CONTENT_KIND_LABEL,
   formatContentText,
+  formatKeywordsLabel,
   formatQuizLevelLabel,
   formatVisibilityLabel,
   type GradesBySchool,
@@ -59,7 +60,7 @@
       key: 'keywords',
       header: '키워드',
       width: 200,
-      render: (row) => formatContentText(row.keywordText),
+      render: (row) => formatContentText(formatKeywordsLabel(row.keywords)),
     },
     {
       key: 'question',
lib/data/repositories/content-quiz-repository.ts
--- lib/data/repositories/content-quiz-repository.ts
+++ lib/data/repositories/content-quiz-repository.ts
@@ -1,7 +1,7 @@
 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 { ContentKeyword, QuizItem } from '@/lib/domain/content';
 import type { ContentQuery } from '@/lib/domain/content-query';
 import type { QuizFormValues } from '@/lib/domain/content-form';
 
@@ -42,6 +42,23 @@
 }
 
 /** `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: [] };
@@ -85,17 +102,15 @@
     throw new Error('퀴즈 응답에 cntntsId가 없습니다.');
   }
 
-  const points = raw.quizPoint;
   const schulGrd = toSchoolGrades(raw.schulGrdList);
 
   return {
     quizId,
-    keywordText: readString(raw, 'quizTitle') ?? '',
+    keywords: toKeywords(raw.keywordList),
     level: readString(raw, 'cntntsQuizLev') ?? '',
     question: readString(raw, 'cntntsQuizQuestion') ?? '',
     explanation: readString(raw, 'cntntsQuizExplan') ?? '',
     answer: readString(raw, 'cntntsQuizAnswer') ?? '',
-    points: typeof points === 'number' ? points : null,
     schoolCode: schulGrd.schoolCode,
     grades: schulGrd.grades,
     isVisible: readString(raw, 'useYn') !== 'N',
@@ -203,13 +218,7 @@
   return toQuizItem(result.data);
 }
 
-/**
- * ⚠️ 백엔드가 키워드 목록을 받도록 바뀌는 중이다(`quizTitle`은 없어질 예정). **이름이 아직
- * 확정되지 않아** 여기 한 곳에 모아 둔다 — 확정되면 이 세 값만 고치면 요청·전송이 모두 따라간다.
- *
- * 지금 값은 콘텐츠 API(`/api/v1/mngr/cntntns`)의 이름을 그대로 빌린 잠정치다. 백엔드가 아직
- * 이 필드를 모르므로 **보내도 무시되거나 실패한다**(사용자 확정 — 이름만 나중에 바꾸기로 함).
- */
+/** 키워드 목록의 필드명. 백엔드 `CntntsKeywordVo`와 같다(콘텐츠 API와 동일한 구조다). */
 const QUIZ_KEYWORD_FIELD = {
   /** 목록 자체의 이름. form에서는 `keywordList[0].…` 형태가 된다. */
   list: 'keywordList',
@@ -222,14 +231,6 @@
 type QuizKeywordPayload = Record<string, string>;
 
 type QuizWritePayload = {
-  /** 값이 있을 때만 실어 보낸다 — 비어 있는 채로 보내면 백엔드가 NULL로 덮어쓴다. */
-  quizPoint?: string;
-  /**
-   * TODO(백엔드): 키워드 목록으로 대체되면 이 필드를 지운다. 그전까지 남겨 두는 이유는 수정
-   * SQL이 `QUIZ_TITLE = #{quizTitle}`로 조건 없이 덮어써서, 빼는 순간 기존 키워드가 지워지기
-   * 때문이다.
-   */
-  quizTitle: string;
   cntntsQuizLev: string;
   cntntsQuizQuestion: string;
   cntntsQuizExplan: string;
@@ -245,8 +246,6 @@
  */
 function buildQuizPayload(values: QuizFormValues): QuizWritePayload {
   return {
-    ...(values.points === '' ? {} : { quizPoint: values.points }),
-    quizTitle: values.keywords.map((item) => item.keyword).join(', '),
     cntntsQuizLev: values.level,
     cntntsQuizQuestion: values.question,
     cntntsQuizExplan: values.explanation,
@@ -271,8 +270,6 @@
   payload: QuizWritePayload
 ): Record<string, string | number | undefined> {
   const params: Record<string, string | number | undefined> = {
-    quizPoint: payload.quizPoint,
-    quizTitle: payload.quizTitle,
     cntntsQuizLev: payload.cntntsQuizLev,
     cntntsQuizQuestion: payload.cntntsQuizQuestion,
     cntntsQuizExplan: payload.cntntsQuizExplan,
lib/domain/content-form.ts
--- lib/domain/content-form.ts
+++ lib/domain/content-form.ts
@@ -77,11 +77,6 @@
 };
 
 export type QuizFormValues = GradeSelection & {
-  /**
-   * 기존 포인트. 화면에 입력 칸이 없지만(기획에 없다) 수정 시 그대로 돌려보내야 한다 —
-   * 백엔드 UPDATE가 `QUIZ_POINT = #{quizPoint}`로 무조건 덮어써서, 안 보내면 지워진다.
-   */
-  points: string;
   level: string;
   keywords: ContentKeyword[];
   question: string;
lib/domain/content.ts
--- lib/domain/content.ts
+++ lib/domain/content.ts
@@ -62,21 +62,6 @@
   description: string;
 };
 
-/**
- * 퀴즈의 `quizTitle`을 키워드 줄로 되돌린다.
- *
- * 저장할 때 키워드를 쉼표로 이어 한 칸에 넣으므로 **설명은 남지 않는다**(사용자 확정).
- * 그래서 수정 화면을 열면 키워드만 채워지고 설명은 빈 칸이며, 그대로 저장하면 설명은 계속
- * 비어 있다. 백엔드가 키워드 목록을 받게 되면 이 함수는 필요 없어진다.
- */
-export function keywordsFromText(text: string): ContentKeyword[] {
-  return text
-    .split(',')
-    .map((part) => part.trim())
-    .filter(Boolean)
-    .map((keyword) => ({ keyword, description: '' }));
-}
-
 /** 등록 폼이 처음 보여 주는 키워드 줄 수(사용자 확정 — 종전 2줄에서 1줄로). */
 export const DEFAULT_KEYWORD_ROWS = 1;
 
@@ -113,13 +98,12 @@
 /** 금융OX퀴즈 한 건. 백엔드 `MngrQuizVo`. */
 export type QuizItem = {
   quizId: string;
-  /** 백엔드 `quizTitle` — 목록의 「키워드」 열이다(제목이 아니다). */
-  keywordText: string;
+  /** 백엔드 `keywordList` — 목록의 「키워드」 열이다. 항목마다 설명을 갖는다. */
+  keywords: ContentKeyword[];
   level: string;
   question: string;
   explanation: string;
   answer: string;
-  points: number | null;
   /** 공통코드 `COM_SCHUL_CD` 코드값. */
   schoolCode: string;
   /** 공통코드 `GRD_CD` 코드값. */
Add a comment
List