File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
/**
* 게시판 목록(공지사항·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<BoardType, string> = {
notice: '/boards/notices',
inquiry: '/boards/inquiries',
faq: '/boards/faqs',
};
/** 필터 select의 "전체" 선택지 값. 빈 문자열이면 URL에서 생략되어 링크가 짧아진다. */
export const FILTER_ALL = '';
export type BoardSearchFieldOption = { value: string; label: string };
/**
* 화면별 검색 대상.
*
* **백엔드가 거를 수 있는 것만 남긴다**(사용자 확정). 목록 쿼리가 지원하는 것은 제목
* (`searchCondition=1`)과 노출여부(`searchUseYn`) 둘뿐이라, 시안에 있던 내용·질문자명·
* 전화번호·답변내용은 감춘다 — 골라도 걸러지지 않으면 고장으로 보인다.
*
* 1:1문의는 제목이 없어 남는 대상이 하나도 없다. 그래서 검색 상자 자체를 감춘다.
* 백엔드에 조건이 추가되면 여기에 줄을 되살리면 된다.
*/
export const BOARD_SEARCH_FIELDS: Record<BoardType, readonly BoardSearchFieldOption[]> = {
notice: [
...(IS_BOARD_CATEGORY_ENABLED
? ([{ value: 'category', label: '구분' }] as BoardSearchFieldOption[])
: []),
{ value: 'title', label: '제목' },
],
// 1:1문의는 제목 없이 내용만 쓴다 — 백엔드가 내용을 못 걸러 남는 대상이 없다.
inquiry: [],
faq: [{ value: 'title', label: '제목' }],
};
/** 화면별로 노출하는 필터 — 화면이 무엇을 그릴지 판단하는 근거이자, 이 파일의 검증 기준이다. */
export const BOARD_QUERY_FIELDS: Record<
BoardType,
{ category: boolean; type: boolean; visibility: boolean; answered: boolean }
> = {
// 유형(bbsCd)·답변여부는 백엔드 목록 쿼리에 조건이 없어 감춘다(사용자 확정).
notice: { category: IS_BOARD_CATEGORY_ENABLED, type: false, visibility: true, answered: false },
inquiry: { category: IS_BOARD_CATEGORY_ENABLED, type: false, visibility: false, answered: false },
faq: { category: IS_BOARD_CATEGORY_ENABLED, type: false, 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<string, string | string[] | undefined>;
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<BoardPostQuery> = {}
): 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;
}