/** * 게시판 목록(공지사항·1:1문의·FAQ)의 검색·필터·페이징 조건 — 순수 규칙(허용 값·기본값·URL * 직렬화)만 담는다. next/react 의존이 없다. * * 학생/관리자 회원 목록은 화면마다 query 파일을 따로 두었지만 **게시판 셋은 한 파일로 합쳤다.** * 세 화면이 같은 API·같은 엔티티를 쓰고 조건도 같은 모양(구분·유형·검색어·페이징)이라, 나누면 * 같은 규칙이 세 벌로 복제되어 드리프트한다. 화면마다 다른 것은 "어떤 필터를 노출하는가"뿐이므로 * 그 목록만 `BOARD_QUERY_FIELDS`로 선언해 두고 파싱·직렬화 로직은 공유한다. * * 쓰지 않는 필터는 화면에서 렌더하지 않으면 그만이다(값이 빈 문자열로 남아 필터가 통과된다). */ import { BOARD_CATEGORY_OPTIONS, IS_BOARD_CATEGORY_ENABLED, INQUIRY_TYPE_OPTIONS, type BoardType, } from '@/lib/domain/board-post'; /** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 표를 참조한다. */ export const BOARD_PATHS: Record = { notice: '/boards/notices', inquiry: '/boards/inquiries', faq: '/boards/faqs', }; /** 필터 select의 "전체" 선택지 값. 빈 문자열이면 URL에서 생략되어 링크가 짧아진다. */ export const FILTER_ALL = ''; export type BoardSearchFieldOption = { value: string; label: string }; /** * 화면별 검색 대상. * * 백엔드 목록 쿼리는 제목(`searchCondition=1`) 하나만 지원하지만, 사용자 확인 결과 **전체를 받아 * 프론트에서 필터링**하기로 해서 시안의 검색 대상을 모두 제공할 수 있다(Repository 주석 참조). */ export const BOARD_SEARCH_FIELDS: Record = { notice: [ ...(IS_BOARD_CATEGORY_ENABLED ? ([{ value: 'category', label: '구분' }] as BoardSearchFieldOption[]) : []), { value: 'title', label: '제목' }, ], // 1:1문의는 제목 없이 내용만 쓴다 — 제목 검색은 대상이 없다. inquiry: [ { value: 'authorName', label: '질문자명' }, { value: 'authorPhoneNumber', label: '전화번호' }, { value: 'content', label: '내용' }, { value: 'answerContent', label: '답변내용' }, ], faq: [ { value: 'title', label: '제목' }, { value: 'content', label: '내용' }, ], }; /** 화면별로 노출하는 필터 — 화면이 무엇을 그릴지 판단하는 근거이자, 이 파일의 검증 기준이다. */ export const BOARD_QUERY_FIELDS: Record< BoardType, { category: boolean; type: boolean; visibility: boolean; answered: boolean } > = { notice: { category: IS_BOARD_CATEGORY_ENABLED, type: false, visibility: true, answered: false }, inquiry: { category: IS_BOARD_CATEGORY_ENABLED, type: true, visibility: false, answered: true }, faq: { category: IS_BOARD_CATEGORY_ENABLED, type: true, visibility: false, answered: false }, }; /** * 화면별 유형 선택지. 공지사항은 유형 항목이 없고, FAQ는 공통코드(`BBS_FAQ_CD`)라 여기 없다 — * 실행 중에 조회한 목록을 화면이 직접 들고 쓴다. */ export const BOARD_TYPE_OPTIONS: Record< BoardType, readonly BoardSearchFieldOption[] > = { notice: [], inquiry: INQUIRY_TYPE_OPTIONS, faq: [], }; /** 사용여부 필터(공지사항) — 시안의 「전체/노출/미노출」. */ export const VISIBILITY_FILTER_OPTIONS: readonly BoardSearchFieldOption[] = [ { value: 'visible', label: '노출' }, { value: 'hidden', label: '미노출' }, ]; /** 답변여부 필터(1:1문의) — 시안의 「전체/답변완료/미답변」. */ export const ANSWERED_FILTER_OPTIONS: readonly BoardSearchFieldOption[] = [ { value: 'answered', label: '답변완료' }, { value: 'unanswered', label: '미답변' }, ]; export const BOARD_PAGE_SIZE_OPTIONS = [10, 30, 50] as const; export type BoardPageSize = (typeof BOARD_PAGE_SIZE_OPTIONS)[number]; export const DEFAULT_BOARD_PAGE_SIZE: BoardPageSize = 10; const DEFAULT_PAGE = 1; const MAX_KEYWORD_LENGTH = 100; export type BoardPostQuery = { /** 구분 코드. 빈 문자열이면 전체. */ category: string; /** 유형 코드. 빈 문자열이면 전체. */ type: string; /** 'visible' | 'hidden' | '' */ visibility: string; /** 'answered' | 'unanswered' | '' */ answered: string; searchField: string; keyword: string; page: number; pageSize: BoardPageSize; }; type RawSearchParams = Record; function readParam(params: RawSearchParams, key: string): string | undefined { const value = params[key]; return Array.isArray(value) ? value[0] : value; } /** 허용 목록에 있는 값만 통과시키고, 아니면 빈 문자열(전체)로 떨어뜨린다. */ function readOneOf( params: RawSearchParams, key: string, allowed: readonly { value: string }[] ): string { const raw = readParam(params, key) ?? ''; return allowed.some((option) => option.value === raw) ? raw : FILTER_ALL; } function isBoardPageSize(value: number): value is BoardPageSize { return (BOARD_PAGE_SIZE_OPTIONS as readonly number[]).includes(value); } /** * URL의 searchParams를 검증된 `BoardPostQuery`로 정규화한다. 값이 없거나 허용 목록을 벗어나면 * 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라 신뢰하지 않는다. * * 화면이 쓰지 않는 필터(`BOARD_QUERY_FIELDS`)는 URL에 실려 와도 무시한다 — 예를 들어 FAQ에 * `?visibility=hidden`을 붙여도 FAQ에는 사용여부 필터가 없으므로 결과가 달라지지 않아야 한다. */ export function parseBoardPostQuery( boardType: BoardType, searchParams: RawSearchParams ): BoardPostQuery { const fields = BOARD_QUERY_FIELDS[boardType]; const searchFields = BOARD_SEARCH_FIELDS[boardType]; const pageRaw = Number(readParam(searchParams, 'page')); const pageSizeRaw = Number(readParam(searchParams, 'pageSize')); const searchFieldRaw = readParam(searchParams, 'searchField') ?? ''; return { category: fields.category ? readOneOf(searchParams, 'category', BOARD_CATEGORY_OPTIONS) : FILTER_ALL, // FAQ 유형은 공통코드라 허용 목록을 여기서 알 수 없다. 값은 그대로 받고 목록을 거르는 데만 // 쓰며(모르는 코드면 결과가 비는 것으로 끝난다), 저장 검증은 Server Action이 코드표로 한다. type: fields.type ? boardType === 'faq' ? (readParam(searchParams, 'type') ?? '').trim().slice(0, MAX_KEYWORD_LENGTH) : readOneOf(searchParams, 'type', BOARD_TYPE_OPTIONS[boardType]) : FILTER_ALL, visibility: fields.visibility ? readOneOf(searchParams, 'visibility', VISIBILITY_FILTER_OPTIONS) : FILTER_ALL, answered: fields.answered ? readOneOf(searchParams, 'answered', ANSWERED_FILTER_OPTIONS) : FILTER_ALL, searchField: searchFields.some((field) => field.value === searchFieldRaw) ? searchFieldRaw : searchFields[0].value, keyword: (readParam(searchParams, 'keyword') ?? '') .trim() .slice(0, MAX_KEYWORD_LENGTH), page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE, pageSize: isBoardPageSize(pageSizeRaw) ? pageSizeRaw : DEFAULT_BOARD_PAGE_SIZE, }; } /** * `BoardPostQuery`(+ 부분 override)를 해당 게시판 링크로 직렬화한다. `parseBoardPostQuery`의 * 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다. */ export function buildBoardPostHref( boardType: BoardType, query: BoardPostQuery, overrides: Partial = {} ): string { const merged = { ...query, ...overrides }; const params = new URLSearchParams(); if (merged.category) params.set('category', merged.category); if (merged.type) params.set('type', merged.type); if (merged.visibility) params.set('visibility', merged.visibility); if (merged.answered) params.set('answered', merged.answered); if (merged.searchField !== BOARD_SEARCH_FIELDS[boardType][0].value) { params.set('searchField', merged.searchField); } if (merged.keyword) params.set('keyword', merged.keyword); if (merged.pageSize !== DEFAULT_BOARD_PAGE_SIZE) { params.set('pageSize', String(merged.pageSize)); } if (merged.page !== DEFAULT_PAGE) params.set('page', String(merged.page)); const queryString = params.toString(); const path = BOARD_PATHS[boardType]; return queryString ? `${path}?${queryString}` : path; }