import 'server-only'; import { cache } from 'react'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import type { BannedWord } from '@/lib/domain/banned-word'; import type { BannedWordQuery } from '@/lib/domain/banned-word-query'; import type { ExcelUploadResult } from '@/lib/domain/banned-word-form'; /** * 금칙어 Repository — `TB_SYS_BEN_WORD`. * * ``` * GET /api/v1/mngr/benWord/pagination searchCondition=1 + searchKeyword로 부분검색 * POST /api/v1/mngr/benWord 어노테이션 없음 → form. 중복이면 실패 응답 * DELETE /api/v1/mngr/benWord/{benWordSn} soft delete(DEL_YN) * POST /api/v1/mngr/benWord/excel multipart, 파트 이름 `file` * ``` * * ⚠️ 백엔드 결함(보고함, 미수정) * 1. 목록 정렬이 기획과 반대다. `ROW_NUMBER() OVER (ORDER BY frst_reg_dt DESC)`로 매긴 rnum을 * 다시 `ORDER BY RNUM DESC`로 뒤집어 **오래된 것이 먼저** 온다. 기획은 최신순이다. 그래서 * 페이징을 백엔드에 맡기지 않고 한 번에 받아 여기서 정렬·절단한다(rnum 1 = 최신). * 2. `DELETE`의 인자에 `@PathVariable`이 없다. 경로만 보내면 benWordSn이 null이라 아무것도 * 지워지지 않는다. 경로와 쿼리에 같은 값을 함께 실어 보낸다 — 어노테이션이 붙어도 그대로 * 동작한다. */ const BEN_WORD_PATH = '/api/v1/mngr/benWord'; // TODO(백엔드): 정렬이 고쳐지면 서버 페이징으로 되돌린다. 그전까지는 이 상한을 넘는 금칙어가 // 생기면 목록이 조용히 잘린다. const FETCH_LIMIT = 1000; 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; } function toBannedWord(raw: unknown): BannedWord[] { if (!isRecord(raw)) { return []; } const id = Number(raw.benWordSn); if (!Number.isInteger(id)) { return []; } return [ { id, word: readString(raw, 'benWordNm') ?? '', createdAt: readString(raw, 'frstRegDtStr'), }, ]; } /** rnum 1이 최신이라 오름차순이 곧 최신순이다. */ function byNewestFirst(raw: unknown): number { return isRecord(raw) ? Number(raw.rnum ?? 0) : 0; } const fetchAllBannedWords = cache( async (keyword: string): Promise => { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${BEN_WORD_PATH}/pagination`, { method: 'GET', query: { pageIndex: 1, recordCountPerPage: FETCH_LIMIT, ...(keyword ? { searchCondition: '1', searchKeyword: keyword } : {}), }, accessToken: accessToken ?? undefined, cache: 'no-store', }); if (!result.ok) { throw new BackendRequestError(result); } const data = result.data; if (!isRecord(data) || !Array.isArray(data.list)) { throw new Error('금칙어 목록 응답의 형식이 올바르지 않습니다.'); } return [...data.list] .sort((a, b) => byNewestFirst(a) - byNewestFirst(b)) .flatMap(toBannedWord); } ); export async function fetchBannedWords( query: BannedWordQuery ): Promise<{ items: BannedWord[]; totalCount: number }> { const all = await fetchAllBannedWords(query.keyword); const start = (query.page - 1) * query.pageSize; return { items: all.slice(start, start + query.pageSize), totalCount: all.length, }; } export type CreateBannedWordResult = | { status: 'success' } | { status: 'duplicate' }; export async function createBannedWord( word: string ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(BEN_WORD_PATH, { method: 'POST', form: { benWordNm: word }, accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { // 중복은 정상 흐름이라 예외로 올리지 않는다 — 백엔드가 "이미 등록된 단어 입니다"로 답한다. if (result.message.includes('이미 등록된')) { return { status: 'duplicate' }; } throw new BackendRequestError(result); } return { status: 'success' }; } export async function deleteBannedWord(id: number): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${BEN_WORD_PATH}/${id}`, { method: 'DELETE', query: { benWordSn: id }, accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { throw new BackendRequestError(result); } } export async function uploadBannedWordExcel( file: File ): Promise { const accessToken = await getSessionAccessToken(); const multipart = new FormData(); multipart.set('file', file); const result = await backendFetch(`${BEN_WORD_PATH}/excel`, { method: 'POST', multipart, accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, }); const data = result.ok ? result.data : null; const errors = isRecord(data) && Array.isArray(data.errors) ? data.errors.filter((entry): entry is string => typeof entry === 'string') : []; if (!result.ok) { // 검증 실패는 fail(300) + data.errors로 온다. 통신 오류와 구분해 화면에 그대로 보여준다. if (errors.length > 0) { return { successCount: 0, errors }; } throw new BackendRequestError(result); } return { successCount: isRecord(data) && Number.isInteger(Number(data.successCount)) ? Number(data.successCount) : 0, errors, }; }