임동욱 임동욱 09-03
feat: OX퀴즈 노출 문구를 정답별 공통코드에서 받는다
O와 X의 문구 목록이 서로 달라 그룹이 둘로 갈린다 — QUIZ_ANSWER_O · QUIZ_ANSWER_X.
정답 코드명을 접미로 붙여 그룹 이름을 만들므로, 정답 선택지가 늘어도 그룹만 등록하면
화면은 손대지 않는다.

기타는 코드가 아니라 직접 입력을 여는 자리라 프론트가 목록 뒤에 붙이고, 고르면 사용자가
적은 문구가 전송된다. 서버 검증도 하드코딩 표 대신 같은 그룹을 조회해 대조한다.

Co-Authored-By: Claude Opus 5 
@8e393f34555609ec423d325faf201372d89c100c
app/(protected)/(basic)/contents/_actions.ts
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -5,6 +5,7 @@
 import { verifySession } from '@/lib/auth/dal';
 import { BackendRequestError } from '@/lib/http/backend-fetch';
 import {
+  fetchQuizAnswerMessages,
   fetchQuizAnswerNames,
   fetchSchoolGradeCodes,
 } from '@/lib/data/repositories/common-code-repository';
@@ -393,7 +394,8 @@
     const validation = validateQuizForm(
       readQuizValues(formData, answerOptions),
       await fetchSchoolGradeCodes(),
-      answerOptions
+      answerOptions,
+      await fetchQuizAnswerMessages(answerOptions)
     );
     if (!validation.ok) {
       return { status: 'error', errors: validation.errors };
@@ -426,7 +428,8 @@
     const validation = validateQuizForm(
       readQuizValues(formData, answerOptions),
       await fetchSchoolGradeCodes(),
-      answerOptions
+      answerOptions,
+      await fetchQuizAnswerMessages(answerOptions)
     );
     if (!validation.ok) {
       return { status: 'error', errors: validation.errors };
app/(protected)/(basic)/contents/quizzes/[quizId]/page.tsx
--- app/(protected)/(basic)/contents/quizzes/[quizId]/page.tsx
+++ app/(protected)/(basic)/contents/quizzes/[quizId]/page.tsx
@@ -2,6 +2,7 @@
 import { notFound } from 'next/navigation';
 import { verifySession } from '@/lib/auth/dal';
 import {
+  fetchQuizAnswerMessages,
   fetchQuizAnswerNames,
   fetchSchoolGradeCodes,
 } from '@/lib/data/repositories/common-code-repository';
@@ -22,6 +23,8 @@
     fetchSchoolGradeCodes(),
     fetchQuizAnswerNames(),
   ]);
+  // 문구 그룹 이름이 정답 코드명에서 나오므로 정답 목록을 받은 뒤에 조회한다.
+  const messageOptionsByAnswer = await fetchQuizAnswerMessages(answerOptions);
 
   const { quizId } = await params;
   const item = await fetchQuizDetail(quizId);
@@ -34,6 +37,7 @@
       schools={schools}
       gradesBySchool={gradesBySchool}
       answerOptions={answerOptions}
+      messageOptionsByAnswer={messageOptionsByAnswer}
       item={item}
     />
   );
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
@@ -11,7 +11,6 @@
 import { submitFormAction } from '@/app/_hooks/submit-form-action';
 import {
   ANSWER_MESSAGE_ETC,
-  ANSWER_MESSAGE_OPTIONS,
   QUIZ_LEVEL_OPTIONS,
   type QuizItem,
   type GradesBySchool,
@@ -37,6 +36,11 @@
   gradesBySchool: GradesBySchool;
   /** 공통코드 `QUIZ_ANSWER` 상세코드의 코드명 — 이 문자열이 그대로 저장된다. */
   answerOptions: readonly string[];
+  /**
+   * 정답별 「노출할 문구」 선택지. O와 X의 목록이 달라 그룹이 갈린다
+   * (`QUIZ_ANSWER_O`·`QUIZ_ANSWER_X`).
+   */
+  messageOptionsByAnswer: Record<string, readonly CommonCode[]>;
   item?: QuizItem;
 }
 
@@ -44,14 +48,16 @@
  * 콘텐츠 등록·수정 (금융OX퀴즈) — 시안 5402:12360.
  *
  * 정답여부는 선택지(공통코드 `QUIZ_ANSWER`)마다 한 줄이고 각 줄이 「사용자에게 노출할
- * 문구」를 갖는다. 문구는 모두 백엔드로 보낸다 — 정답이 아닌 쪽에도 보여 줄 문구가 필요하기
- * 때문이다(사용자 확정). 「기타」를 고르면 문구를 직접 적는다. 앞의 라디오는 어느 쪽이
- * 정답인지만 정한다.
+ * 문구」를 갖는다. 문구 선택지는 정답마다 다른 공통코드에서 온다 — O는 `QUIZ_ANSWER_O`,
+ * X는 `QUIZ_ANSWER_X`다(사용자 확정). 문구는 모두 백엔드로 보낸다 — 정답이 아닌 쪽에도
+ * 보여 줄 문구가 필요하기 때문이다. 「기타」를 고르면 문구를 직접 적고 그 값이 전송된다.
+ * 앞의 라디오는 어느 쪽이 정답인지만 정한다.
  */
 export function QuizForm({
   schools,
   gradesBySchool,
   answerOptions,
+  messageOptionsByAnswer,
   item,
 }: QuizFormProps) {
   const isEdit = item !== undefined;
@@ -67,10 +73,23 @@
     Object.fromEntries(
       answerOptions.map((option) => [
         option,
-        { code: ANSWER_MESSAGE_OPTIONS[0].value, text: '' },
+        // 코드가 아직 등록되지 않았을 수 있다 — 그때는 「기타」로 시작한다.
+        {
+          code: messageOptionsByAnswer[option]?.[0]?.code ?? ANSWER_MESSAGE_ETC,
+          text: '',
+        },
       ])
     )
   );
+
+  /** 코드 목록 + 「기타」. 기타는 코드가 아니라 직접 입력을 여는 자리라 항상 뒤에 붙인다. */
+  const messageOptionsOf = (option: string) => [
+    ...(messageOptionsByAnswer[option] ?? []).map((code) => ({
+      value: code.code,
+      label: code.label,
+    })),
+    { value: ANSWER_MESSAGE_ETC, label: '기타' },
+  ];
 
   const patchMessage = (
     option: string,
@@ -175,10 +194,7 @@
                   <FoxSelect
                     size="md"
                     ariaLabel={`${option} 노출 문구`}
-                    options={ANSWER_MESSAGE_OPTIONS.map((message) => ({
-                      value: message.value,
-                      label: message.label,
-                    }))}
+                    options={messageOptionsOf(option)}
                     value={messages[option].code}
                     onValueChange={(code) => patchMessage(option, { code })}
                   />
app/(protected)/(basic)/contents/quizzes/new/page.tsx
--- app/(protected)/(basic)/contents/quizzes/new/page.tsx
+++ app/(protected)/(basic)/contents/quizzes/new/page.tsx
@@ -1,6 +1,7 @@
 import type { Metadata } from 'next';
 import { verifySession } from '@/lib/auth/dal';
 import {
+  fetchQuizAnswerMessages,
   fetchQuizAnswerNames,
   fetchSchoolGradeCodes,
 } from '@/lib/data/repositories/common-code-repository';
@@ -16,11 +17,14 @@
     fetchSchoolGradeCodes(),
     fetchQuizAnswerNames(),
   ]);
+  // 문구 그룹 이름이 정답 코드명에서 나오므로 정답 목록을 받은 뒤에 조회한다.
+  const messageOptionsByAnswer = await fetchQuizAnswerMessages(answerOptions);
   return (
     <QuizForm
       schools={schools}
       gradesBySchool={gradesBySchool}
       answerOptions={answerOptions}
+      messageOptionsByAnswer={messageOptionsByAnswer}
     />
   );
 }
lib/data/repositories/common-code-repository.ts
--- lib/data/repositories/common-code-repository.ts
+++ lib/data/repositories/common-code-repository.ts
@@ -182,6 +182,28 @@
   return details.map((detail) => detail.name);
 }
 
+/**
+ * 정답별 「노출할 문구」 선택지. O와 X의 목록이 달라 그룹이 둘로 갈린다 —
+ * `QUIZ_ANSWER_O` · `QUIZ_ANSWER_X`(사용자 확정).
+ *
+ * 정답 코드명(`O`/`X`)을 접미로 붙여 그룹 이름을 만든다. 정답 선택지가 늘면 같은 규칙으로
+ * 그룹만 등록하면 화면은 손대지 않아도 된다.
+ */
+export async function fetchQuizAnswerMessages(
+  answerNames: readonly string[]
+): Promise<Record<string, CommonCode[]>> {
+  const entries = await Promise.all(
+    answerNames.map(
+      async (answer) =>
+        [
+          answer,
+          await fetchCommonCodes(`${CODE_GROUP.quizAnswer}_${answer}`),
+        ] as const
+    )
+  );
+  return Object.fromEntries(entries);
+}
+
 export type SchoolGradeCodes = {
   /** `COM_SCHUL_CD` — 학교 선택지. */
   schools: CommonCode[];
lib/domain/content-form.ts
--- lib/domain/content-form.ts
+++ lib/domain/content-form.ts
@@ -5,7 +5,6 @@
 
 import {
   ANSWER_MESSAGE_ETC,
-  ANSWER_MESSAGE_OPTIONS,
   MIN_COMIC_CUTS,
   QUIZ_LEVEL_OPTIONS,
   gradesOf,
@@ -235,7 +234,8 @@
 export function validateQuizForm(
   values: QuizFormValues,
   codes: SchoolGradeOptions,
-  answerOptions: readonly string[]
+  answerOptions: readonly string[],
+  messageOptionsByAnswer: Record<string, readonly CommonCode[]>
 ): ValidationResult<QuizFormValues> {
   const errors: ContentFormErrors = {};
   validateGrades(values, codes, errors);
@@ -264,9 +264,11 @@
 
   // 정답 선택지 줄을 각각 검사한다 — 정답이 아닌 쪽도 보내므로 모두 유효해야 한다.
   const answerMessages = values.answerMessages.map((message) => {
+    const allowedCodes = messageOptionsByAnswer[message.answer] ?? [];
     if (
       message.code !== '' &&
-      !ANSWER_MESSAGE_OPTIONS.some((option) => option.value === message.code)
+      message.code !== ANSWER_MESSAGE_ETC &&
+      !allowedCodes.some((option) => option.code === message.code)
     ) {
       errors[`answerMessage${message.answer}Code`] =
         '노출 문구를 다시 선택해 주세요.';
lib/domain/content.ts
--- lib/domain/content.ts
+++ lib/domain/content.ts
@@ -43,22 +43,18 @@
   { value: '1', label: '하' },
 ];
 
-/** 정답여부에 함께 노출할 문구. 기타를 고르면 직접 입력한다. */
+/**
+ * 정답여부에 함께 노출할 문구. 선택지는 정답마다 다른 공통코드 그룹에서 온다 —
+ * `QUIZ_ANSWER_O` · `QUIZ_ANSWER_X`(사용자 확정). 「기타」만 코드가 아니라 직접 입력을
+ * 여는 자리라 프론트가 붙인다.
+ */
 export const ANSWER_MESSAGE_ETC = 'ETC';
-
-// TODO: 기획이 "추후 정의"로 남긴 기본 3종이다. 확정되면 이 표만 교체한다.
-export const ANSWER_MESSAGE_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
-  { value: 'MSG1', label: '사용자에게 노출할 문구1' },
-  { value: 'MSG2', label: '사용자에게 노출할 문구2' },
-  { value: 'MSG3', label: '사용자에게 노출할 문구3' },
-  { value: ANSWER_MESSAGE_ETC, label: '기타' },
-];
 
 /** 정답여부 한 줄이 갖는 노출 문구. 정답 선택지마다 하나씩 둔다. */
 export type QuizAnswerMessage = {
   /** 어느 정답에 붙는 문구인지 — `QUIZ_ANSWER` 상세코드의 코드명. */
   answer: string;
-  /** `ANSWER_MESSAGE_OPTIONS`의 코드값. */
+  /** 정답별 문구 그룹의 상세코드값, 또는 `ANSWER_MESSAGE_ETC`. */
   code: string;
   /** `code`가 기타일 때만 쓰는 직접 입력 문구. */
   text: string;
Add a comment
List