import 'server-only'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import { BOARD_SETTING_IDS, type BoardPost, type BoardType, } from '@/lib/domain/board-post'; import type { BoardPostQuery } from '@/lib/domain/board-post-query'; /** * 게시판 Repository — 공지사항·1:1문의·FAQ가 **같은 백엔드 API**를 쓰므로 한 파일이 셋을 모두 * 담당하고, 게시판 구분은 `BoardType` → `stngId` 변환으로만 처리한다. * * GET /api/v1/mngr/bbs/pagination (searchStngId 필수) * GET /api/v1/mngr/bbs/{bbsId} * POST /api/v1/mngr/bbs * PUT /api/v1/mngr/bbs/{bbsId} * DELETE /api/v1/mngr/bbs/{bbsId} (논리 삭제 — DEL_YN='Y') * * 아래는 백엔드 저장소(edupay-backend origin/develop 821f655)의 실제 구현을 읽고 확인한 것이다 — * MngrBbsApiController / MngrBbsServiceImpl / MngrBbsMapper.xml / PaginationUtil / CrudLogInterceptor. * * - **쓰기는 모두 JSON이다.** 종전에는 등록만 `@RequestBody`가 없어 폼이었는데 821f655에서 붙었다. * - **작성자·수정자·삭제자는 보내지 않는다.** `CrudLogInterceptor`(MyBatis 플러그인)가 인증 * 주체에서 꺼내 자동 기록한다. 프론트가 보내도 덮어써진다. * - **`bbsCd`는 유형 코드다.** 조회가 코드관리 BBS_FAQ_CD와 조인해 이름(`bbsFaqNm`)까지 준다. * 반면 쓰기는 요청 VO에도 컨트롤러 복사에도 bbsCd가 없어 **유형이 저장되지 않는다**(보고함). * INSERT/UPDATE SQL에는 자리가 있어, 보내는 값은 그대로 두면 VO에 필드가 생기는 즉시 동작한다. * - **앱푸쉬는 끝까지 닿지 않는다.** 요청 VO·컨트롤러까지는 pushYn이 오지만 INSERT/UPDATE/SELECT * SQL 어디에도 PUSH_YN이 없다(보고함). 노출기간(START_DT·END_DT)은 이제 조회까지 온다. * - **구분 값이 없다.** 협의 중이라 화면 입력은 유지하되 조회·전송 모두 비워 둔다(사용자 확정). * * 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용. 캐시: `no-store`(조건이 매 요청 다르고 관리 데이터다). */ const BOARD_LIST_PATH = '/api/v1/mngr/bbs/pagination'; const BOARD_BASE_PATH = '/api/v1/mngr/bbs'; 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; } /** 백엔드의 Y/N 플래그 → boolean. 값이 없거나 Y/N이 아니면 "모름"(null)이다. */ function parseYesNo(value: unknown): boolean | null { if (value === 'Y') return true; if (value === 'N') return false; return null; } /** `inqCnt`는 VO 타입이 String이라 숫자 문자열로 온다 — 숫자로 못 읽으면 null. */ function parseCount(value: unknown): number | null { if (typeof value === 'number') return value; if (typeof value === 'string' && value.trim() !== '') { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } /** * 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`로 둔다. * * `bbsId`만 없으면 예외로 끊는다(fail-fast) — 행의 key이자 수정·삭제의 입력값이라 없으면 목록 * 자체가 성립하지 않는다. 제목·내용은 비어 있어도 화면이 `-`로 표시하면 되므로 끊지 않는다. */ function toBoardPost(raw: unknown): BoardPost { if (!isRecord(raw)) { throw new Error('게시판 응답 항목의 형식이 올바르지 않습니다.'); } const id = readString(raw, 'bbsId'); if (id === null) { throw new Error('게시판 응답에 bbsId가 없습니다.'); } return { id, title: readString(raw, 'bbsNm') ?? '', content: readString(raw, 'bbsCn') ?? '', // 이슈: 구분 값은 백엔드에 아직 없다(협의 중). bbsCd는 구분이 아니라 유형이다. categoryCode: null, typeCode: readString(raw, 'bbsCd'), typeName: readString(raw, 'bbsFaqNm'), attachmentId: readString(raw, 'atchFileId'), isPinned: parseYesNo(raw.hghrkYn), isVisible: parseYesNo(raw.rlsYn), startDate: readString(raw, 'startDt'), endDate: readString(raw, 'endDt'), isPushEnabled: parseYesNo(raw.pushYn), authorName: readString(raw, 'regNm'), createdAt: readString(raw, 'regDt'), viewCount: parseCount(raw.inqCnt), authorPhoneNumber: readString(raw, 'telNo'), authorEmail: readString(raw, 'emlAddr'), answerContent: readString(raw, 'ansCn'), answeredAt: readString(raw, 'ansDt'), answererName: readString(raw, 'answrNm'), answerStatusCode: readString(raw, 'ansSttsCd'), }; } /** 게시판 전체를 한 번에 받아온다(검색·필터·페이징은 호출부가 처리한다). */ async function requestBoardPage( boardType: BoardType, params: Record ): Promise<{ items: BoardPost[]; totalCount: number }> { const accessToken = await getSessionAccessToken(); const result = await backendFetch(BOARD_LIST_PATH, { method: 'GET', query: { searchStngId: BOARD_SETTING_IDS[boardType], ...params }, 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('게시판 목록 응답의 형식이 올바르지 않습니다.'); } const totalCount = typeof data.totalCount === 'number' ? data.totalCount : data.list.length; return { items: data.list.map(toBoardPost), totalCount }; } export type BoardPostPage = { items: BoardPost[]; /** 검색·필터를 적용한 전체 건수. 전체를 손에 쥐고 세므로 확정값이다. */ totalCount: number; }; /** * 검색·필터·페이징이 적용된 목록을 조회한다. * * 정렬은 하지 않는다 — 백엔드 목록 SQL이 `ORDER BY rnum DESC`(= 최초등록일시 최신순)로 고정돼 * 있고 그것이 곧 시안의 "최근등록순"이다. 시안의 정렬 select에도 다른 선택지가 없다. */ export async function fetchBoardPosts( boardType: BoardType, query: BoardPostQuery ): Promise { const params: Record = { pageIndex: query.page, recordCountPerPage: query.pageSize, }; // 백엔드가 거를 수 있는 둘만 넘긴다 — 제목(searchCondition=1)과 노출여부(RLS_YN). // 나머지 조건은 화면에서 감췄다(`BOARD_SEARCH_FIELDS`·`BOARD_QUERY_FIELDS`). const keyword = query.keyword.trim(); if (keyword && query.searchField === 'title') { params.searchCondition = '1'; params.searchKeyword = keyword; } if (query.visibility === 'visible') { params.searchUseYn = 'Y'; } else if (query.visibility === 'hidden') { params.searchUseYn = 'N'; } return requestBoardPage(boardType, params); } /** 단건 조회 — 수정·답변 팝업의 진입점. */ export async function findBoardPostById( bbsId: string ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${BOARD_BASE_PATH}/${bbsId}`, { method: 'GET', accessToken: accessToken ?? undefined, cache: 'no-store', }); if (!result.ok) { throw new BackendRequestError(result); } return result.data === null ? null : toBoardPost(result.data); } /** boolean → 백엔드 Y/N 플래그. */ function toYesNo(value: boolean): string { return value ? 'Y' : 'N'; } export type BoardPostWriteInput = { categoryCode: string; typeCode: string; title: string; content: string; attachmentId: string; isPinned: boolean; isVisible: boolean; isPushEnabled: boolean; startDate: string; endDate: string; }; /** * 등록/수정 공통 파라미터. * * 빈 문자열은 보내지 않는다 — 수정 SQL이 ``로 동적 SET을 만들기 때문에, * 빈 문자열을 보내면 기존 값을 빈 값으로 덮어쓴다(예: 첨부파일을 안 건드렸는데 지워지는 상황). * "값을 안 보냄 = 기존 유지"가 백엔드의 규약이라 그에 맞춘다. */ function buildWriteParams( boardType: BoardType, input: BoardPostWriteInput ): Record { const params: Record = { stngId: BOARD_SETTING_IDS[boardType], bbsNm: input.title, bbsCn: input.content, hghrkYn: toYesNo(input.isPinned), rlsYn: toYesNo(input.isVisible), pushYn: toYesNo(input.isPushEnabled), }; // 이슈: bbsCd(유형)는 요청 VO·컨트롤러 복사에 없어 백엔드가 무시한다. 추가되는 즉시 // 동작하도록 실제 컬럼 이름으로 보낸다. 구분은 백엔드에 자리가 없어 보내지 않는다(협의 중). if (input.typeCode) params.bbsCd = input.typeCode; if (input.attachmentId) params.atchFileId = input.attachmentId; if (input.startDate) params.startDt = input.startDate; if (input.endDate) params.endDt = input.endDate; return params; } export async function createBoardPost( boardType: BoardType, input: BoardPostWriteInput ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(BOARD_BASE_PATH, { method: 'POST', body: buildWriteParams(boardType, input), accessToken: accessToken ?? undefined, cache: 'no-store', // 쓰기 API는 성공해도 `data: null`을 돌려준다(ApiResponseVO.success(null)). canHaveNullData: true, }); if (!result.ok) { throw new BackendRequestError(result); } } export async function updateBoardPost( boardType: BoardType, bbsId: string, input: BoardPostWriteInput ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${BOARD_BASE_PATH}/${bbsId}`, { method: 'PUT', body: buildWriteParams(boardType, input), accessToken: accessToken ?? undefined, cache: 'no-store', // 쓰기 API는 성공해도 `data: null`을 돌려준다(ApiResponseVO.success(null)). canHaveNullData: true, }); if (!result.ok) { throw new BackendRequestError(result); } } export type InquiryAnswerInput = { answerStatusCode: string; answerContent: string; attachmentId: string; }; /** * 1:1문의 답변 저장 — 백엔드에 전용 엔드포인트가 없어 수정 API에 답변 필드를 실어 보낸다. * * 제목·내용은 보내지 않는다(질문자가 쓴 값이라 관리자가 건드릴 이유가 없고, 위에서 적었듯 * 안 보내면 기존 값이 유지된다). `stngId`는 수정 SQL의 WHERE 조건이라 반드시 필요하다. */ export async function saveInquiryAnswer( bbsId: string, input: InquiryAnswerInput ): Promise { const accessToken = await getSessionAccessToken(); const payload: Record = { stngId: BOARD_SETTING_IDS.inquiry, ansSttsCd: input.answerStatusCode, }; if (input.answerContent) payload.ansCn = input.answerContent; if (input.attachmentId) payload.atchFileId = input.attachmentId; const result = await backendFetch(`${BOARD_BASE_PATH}/${bbsId}`, { method: 'PUT', body: payload, accessToken: accessToken ?? undefined, cache: 'no-store', // 쓰기 API는 성공해도 `data: null`을 돌려준다(ApiResponseVO.success(null)). canHaveNullData: true, }); if (!result.ok) { throw new BackendRequestError(result); } } /** 삭제 — 백엔드가 물리 삭제가 아니라 `DEL_YN='Y'`로 표시만 바꾼다. */ export async function deleteBoardPost(bbsId: string): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${BOARD_BASE_PATH}/${bbsId}`, { method: 'DELETE', accessToken: accessToken ?? undefined, cache: 'no-store', // 쓰기 API는 성공해도 `data: null`을 돌려준다(ApiResponseVO.success(null)). canHaveNullData: true, }); if (!result.ok) { throw new BackendRequestError(result); } }