import 'server-only'; import { cache } from 'react'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import { CODE_GROUP, type CommonCode, type CommonCodeDetail, type CommonCodeGroup, } from '@/lib/domain/common-code'; import type { QuizAnswer } from '@/lib/domain/content'; import type { CommonCodeDetailValues, CommonCodeGroupValues, } from '@/lib/domain/common-code-form'; /** * 공통코드 Repository — 그룹(`TB_SYS_COM_CD`)과 상세(`TB_SYS_COM_CD_DTL`) 두 층을 모두 맡는다. * * ``` * GET /api/v1/mngr/code/list 그룹 목록 (ROLE_ADMIN) * GET /api/v1/mngr/code/list/{comCd} 그룹의 상세코드 목록 * POST /api/v1/mngr/code 그룹 등록 — @RequestBody(JSON) * PUT /api/v1/mngr/code/{comCd} 그룹 수정 — @RequestBody(JSON) * DELETE /api/v1/mngr/code/{comCd} 그룹 삭제 — soft delete(DEL_YN) * POST /api/v1/mngr/code/detail 상세 등록 — 어노테이션 없음 → form * PUT /api/v1/mngr/code/{comCd}/{comDtlCd} 상세 수정 — @RequestBody(JSON) * DELETE /api/v1/mngr/code/{comCd}/{comDtlCd} 상세 삭제 — soft delete * ``` * * 백엔드(edupay-backend, develop)의 MngrCodeApiController / MngrCodeMapper.xml을 읽고 확인한 것: * * - **본문 형식이 엔드포인트마다 다르다.** 상세 등록만 `@ParameterObject`(form)이고 나머지 쓰기는 * 전부 `@RequestBody`(JSON)다 — 한쪽으로 통일해 보내면 반대쪽이 조용히 깨진다. * - **두 목록 모두 페이징이 없다**(전체 반환). 그래서 총건수는 정확하고, 자르는 일은 호출부가 한다. * - **그룹 검색은 완전일치다**(`com_cd = #{}` / `cd_nm = #{}`). 시안은 부분검색이라 검색 파라미터를 * 쓰지 않고 전체를 받아 호출부가 거른다(사용자 확정 사항). * - **정렬은 고정이다** — 두 목록 다 `ROW_NUMBER() OVER (...)`를 `ORDER BY RNUM DESC`로 뒤집는 * 이중 역순이라 결과는 등록일 오름차순이다. 화면에서 다시 정렬하지 않는다. * - **삭제는 soft delete**이고 조회가 `DEL_YN != 'Y'`로 거른다. * * ⚠️ **백엔드 결함(보고함, 미수정)** * 1. 상세 목록 조회 SQL이 `DTL_CD_EXPLN`을 select하지 않는다 → 상세코드설명이 늘 비어 온다. * 2. 상세 등록 INSERT에도 `DTL_CD_EXPLN`이 없다 → 등록 시 입력한 설명이 저장되지 않는다. * (수정 UPDATE에는 있어 수정으로는 저장된다 — 다만 위 1 때문에 목록에서는 여전히 안 보인다.) * 사용자 지시로 시안대로 화면을 두고 백엔드에 수정을 요청한다 — 고쳐지면 프론트 수정 없이 동작한다. * * 캐시: 조회는 `no-store` — 관리 화면이라 신선도가 우선이다. `cache()`는 한 요청 안의 중복 호출만 * 막는다(화면이 그리기용으로, Server Action이 검증용으로 같은 목록을 부른다). */ const CODE_PATH = '/api/v1/mngr/code'; 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]; return typeof value === 'string' && value.length > 0 ? value : null; } async function requestList(path: string): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(path, { method: 'GET', accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { throw new BackendRequestError(result); } // 코드가 한 건도 없는 그룹은 빈 목록으로 다룬다 — 학년(`GRD_CD`)처럼 상위 코드에 따라 // 결과가 비는 조회가 있어 이것을 오류로 보면 화면 전체가 멎는다. if (result.data === null || result.data === undefined) { return []; } if (!Array.isArray(result.data)) { throw new Error('공통코드 응답의 형식이 올바르지 않습니다.'); } return result.data; } /** * 응답 1건 → 도메인 타입. 코드값이 없는 행은 **예외 대신 건너뛴다** — 코드 한 줄이 깨졌다고 * 화면 전체를 못 쓰게 만들 이유가 없다(목록 조회의 fail-fast와 다른 판단이다). */ function toGroup(raw: unknown): CommonCodeGroup[] { if (!isRecord(raw)) { return []; } const comCd = readString(raw, 'comCd'); if (!comCd) { return []; } return [ { comCd, name: readString(raw, 'cdNm') ?? '', description: readString(raw, 'cdExpln'), createdAt: readString(raw, 'frstRegDtStr'), }, ]; } function toDetail(raw: unknown): CommonCodeDetail[] { if (!isRecord(raw)) { return []; } const comDtlCd = readString(raw, 'comDtlCd'); if (!comDtlCd) { return []; } const sortSeq = raw.sortSeq; return [ { comCd: readString(raw, 'comCd') ?? '', comDtlCd, name: readString(raw, 'cdNm') ?? '', // 지금은 백엔드가 내려 주지 않아 늘 null이다(파일 상단 결함 1). description: readString(raw, 'dtlCdExpln'), sortSeq: typeof sortSeq === 'number' ? sortSeq : 0, createdAt: readString(raw, 'frstRegDtStr'), }, ]; } /** 공통코드(그룹) 전체. 검색·페이징은 호출부가 한다(파일 상단 주석 참조). */ export const fetchCodeGroups = cache(async function fetchCodeGroups(): Promise< CommonCodeGroup[] > { const rows = await requestList(`${CODE_PATH}/list`); return rows.flatMap(toGroup); }); /** * 한 그룹의 상세코드 전체. * * `atrbNm`을 주면 백엔드가 `ATRB_NM1 = #{atrbNm}`으로 걸러 준다 — 학년(`GRD_CD`)처럼 상위 * 코드에 딸린 목록을 뽑을 때 쓴다(학교 코드를 넣으면 그 학교의 학년만 온다). */ export const fetchCodeDetails = cache(async function fetchCodeDetails( comCd: string, atrbNm?: string ): Promise { const query = atrbNm ? `?atrbNm=${encodeURIComponent(atrbNm)}` : ''; const rows = await requestList( `${CODE_PATH}/list/${encodeURIComponent(comCd)}${query}` ); return rows.flatMap(toDetail); }); /** * 다른 화면이 선택지로 쓰는 최소 표현. 상세코드 조회를 그대로 쓰되 화면이 알 필요 없는 것을 * 덜어 낸다 — 같은 `cache()`를 타므로 한 요청 안에서 왕복이 늘지 않는다. */ export async function fetchCommonCodes( groupCode: string, atrbNm?: string ): Promise { const details = await fetchCodeDetails(groupCode, atrbNm); return details.map((detail) => ({ code: detail.comDtlCd, label: detail.name })); } /** * OX퀴즈 「사용자에게 노출할 문구」 선택지. O와 X가 서로 다른 목록이라 그룹을 둘 다 읽는다. */ export async function fetchQuizAnswerMessages(): Promise< Record > { const [answerO, answerX] = await Promise.all([ fetchCommonCodes(CODE_GROUP.quizAnswerMessage.O), fetchCommonCodes(CODE_GROUP.quizAnswerMessage.X), ]); return { O: answerO, X: answerX }; } export type SchoolGradeCodes = { /** `COM_SCHUL_CD` — 학교 선택지. */ schools: CommonCode[]; /** 학교 코드 → 그 학교의 `GRD_CD` 목록. 학년이 없는 학교는 빈 배열이다. */ gradesBySchool: Record; }; /** * 학교와 학교별 학년을 함께 가져온다. * * 학년 조회에 학교 코드가 필요해 학교 수만큼 왕복한다 — 상세 목록 SQL이 `ATRB_NM1`을 select하지 * 않아 한 번에 받아 화면에서 가르는 방법이 없다. 학교는 몇 개뿐이고 `cache()`가 한 요청 안의 * 중복 호출을 막는다. */ export async function fetchSchoolGradeCodes(): Promise { const schools = await fetchCommonCodes(CODE_GROUP.school); const grades = await Promise.all( schools.map((school) => fetchCommonCodes(CODE_GROUP.grade, school.code)) ); return { schools, gradesBySchool: Object.fromEntries( schools.map((school, index) => [school.code, grades[index]]) ), }; } /* * ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── * 상세 등록만 form이고 나머지는 JSON이다(파일 상단 주석 참조). * 성공 응답은 모두 `ApiResponseVO.success(null)`이라 data가 정상적으로 null이다. */ async function sendWrite( path: string, method: 'POST' | 'PUT' | 'DELETE', payload?: { form: Record } | { body: unknown } ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(path, { method, ...payload, accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, }); if (!result.ok) { throw new BackendRequestError(result); } } export async function createCodeGroup( values: CommonCodeGroupValues ): Promise { await sendWrite(CODE_PATH, 'POST', { body: { comCd: values.comCd, cdNm: values.name, cdExpln: values.description }, }); } /** 코드ID는 경로로만 간다 — 수정 대상이 아니라 대상을 가리키는 값이다. */ export async function updateCodeGroup( comCd: string, values: CommonCodeGroupValues ): Promise { await sendWrite(`${CODE_PATH}/${encodeURIComponent(comCd)}`, 'PUT', { body: { cdNm: values.name, cdExpln: values.description }, }); } export async function deleteCodeGroup(comCd: string): Promise { await sendWrite(`${CODE_PATH}/${encodeURIComponent(comCd)}`, 'DELETE'); } /** 상세 등록만 form이다 — 컨트롤러가 `@RequestBody` 없이 받는다. */ export async function createCodeDetail( values: CommonCodeDetailValues ): Promise { await sendWrite(`${CODE_PATH}/detail`, 'POST', { form: { comCd: values.comCd, comDtlCd: values.comDtlCd, cdNm: values.name, // 백엔드 INSERT가 이 값을 쓰지 않는다(파일 상단 결함 2). 고쳐지면 그대로 저장된다. dtlCdExpln: values.description, sortSeq: values.sortSeq, }, }); } export async function updateCodeDetail( comCd: string, comDtlCd: string, values: CommonCodeDetailValues ): Promise { await sendWrite( `${CODE_PATH}/${encodeURIComponent(comCd)}/${encodeURIComponent(comDtlCd)}`, 'PUT', { body: { cdNm: values.name, dtlCdExpln: values.description, sortSeq: values.sortSeq, }, } ); } export async function deleteCodeDetail( comCd: string, comDtlCd: string ): Promise { await sendWrite( `${CODE_PATH}/${encodeURIComponent(comCd)}/${encodeURIComponent(comDtlCd)}`, 'DELETE' ); }