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,
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`) 하나만 지원하지만, 사용자 확인 결과 **전체를 받아
* 프론트에서 필터링**하기로 해서 시안의 검색 대상을 모두 제공할 수 있다(Repository 주석 참조).
*/
export const BOARD_SEARCH_FIELDS: Record<BoardType, readonly BoardSearchFieldOption[]> = {
notice: [
{ value: 'category', label: '구분' },
{ value: 'title', label: '제목' },
],
inquiry: [
{ value: 'title', label: '제목' },
{ 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: true, type: false, visibility: true, answered: false },
inquiry: { category: true, type: true, visibility: false, answered: true },
faq: { category: true, 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<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;
}