File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
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 {
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<string, unknown> {
return value !== null && typeof value === 'object';
}
function readString(source: Record<string, unknown>, key: string): string | null {
const value = source[key];
return typeof value === 'string' && value.length > 0 ? value : null;
}
async function requestList(path: string): Promise<unknown[]> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(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<CommonCodeDetail[]> {
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<CommonCode[]> {
const details = await fetchCodeDetails(groupCode, atrbNm);
return details.map((detail) => ({ code: detail.comDtlCd, label: detail.name }));
}
/**
* OX퀴즈 정답여부 선택지 — 코드값(`comDtlCd`)이 아니라 **코드명(`cdNm`)을 그대로 값으로**
* 화면·저장에 쓴다(사용자 확정). 백엔드 `CNTNTS_QUIZ_ANSWER`가 문자열을 받기 때문이다.
*/
export async function fetchQuizAnswerNames(): Promise<string[]> {
const details = await fetchCodeDetails(CODE_GROUP.quizAnswer);
return details.map((detail) => detail.name);
}
export type SchoolGradeCodes = {
/** `COM_SCHUL_CD` — 학교 선택지. */
schools: CommonCode[];
/** 학교 코드 → 그 학교의 `GRD_CD` 목록. 학년이 없는 학교는 빈 배열이다. */
gradesBySchool: Record<string, CommonCode[]>;
};
/**
* 학교와 학교별 학년을 함께 가져온다.
*
* 학년 조회에 학교 코드가 필요해 학교 수만큼 왕복한다 — 상세 목록 SQL이 `ATRB_NM1`을 select하지
* 않아 한 번에 받아 화면에서 가르는 방법이 없다. 학교는 몇 개뿐이고 `cache()`가 한 요청 안의
* 중복 호출을 막는다.
*/
export async function fetchSchoolGradeCodes(): Promise<SchoolGradeCodes> {
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<string, string | number | undefined> } | { body: unknown }
): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<null>(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<void> {
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<void> {
await sendWrite(`${CODE_PATH}/${encodeURIComponent(comCd)}`, 'PUT', {
body: { cdNm: values.name, cdExpln: values.description },
});
}
export async function deleteCodeGroup(comCd: string): Promise<void> {
await sendWrite(`${CODE_PATH}/${encodeURIComponent(comCd)}`, 'DELETE');
}
/** 상세 등록만 form이다 — 컨트롤러가 `@RequestBody` 없이 받는다. */
export async function createCodeDetail(
values: CommonCodeDetailValues
): Promise<void> {
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<void> {
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<void> {
await sendWrite(
`${CODE_PATH}/${encodeURIComponent(comCd)}/${encodeURIComponent(comDtlCd)}`,
'DELETE'
);
}