diff options
Diffstat (limited to 'lib/qna/validation.ts')
| -rw-r--r-- | lib/qna/validation.ts | 374 |
1 files changed, 374 insertions, 0 deletions
diff --git a/lib/qna/validation.ts b/lib/qna/validation.ts new file mode 100644 index 00000000..dd140963 --- /dev/null +++ b/lib/qna/validation.ts @@ -0,0 +1,374 @@ +import { qnaView, type QnaViewSelect } from "@/db/schema"; +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server"; +import * as z from "zod"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; + +// Q&A 검색 파라미터 캐시 +export const searchParamsQnaCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<QnaViewSelect>().withDefault([ + { id: "lastActivityAt", desc: true }, // Q&A는 최근 활동순이 기본 + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 전역 검색 + search: parseAsString.withDefault(""), + + // Q&A 특화 필터 + authorDomain: parseAsArrayOf( + z.enum(["partners", "tech", "admin"]) + ).withDefault([]), + + vendorType: parseAsArrayOf( + z.enum(["vendor", "techVendor"]) + ).withDefault([]), + + hasAnswers: parseAsStringEnum([ + "all", + "answered", + "unanswered" + ]).withDefault("all"), + + myQuestions: parseAsStringEnum([ + "all", + "mine", + "others" + ]).withDefault("all"), + + isPopular: parseAsStringEnum([ + "all", + "popular", + "normal" + ]).withDefault("all"), + + // 날짜 범위 필터 + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), + + // 답변 수 범위 필터 + minAnswers: parseAsInteger.withDefault(0), + maxAnswers: parseAsInteger.withDefault(999), + + // 회사별 필터 (특정 회사 선택) + companyNames: parseAsArrayOf(z.string()).withDefault([]), +}); + +// Q&A 생성 스키마 +export const createQnaSchema = z.object({ + title: z + .string() + .min(1, "제목을 입력해주세요") + .max(255, "제목은 255자 이하로 입력해주세요") + .trim(), + content: z + .string() + .min(1, "내용을 입력해주세요") + // .max(10000, "내용은 10000자 이하로 입력해주세요") + .trim(), + category: z + .enum(['engineering', 'procurement', 'technical_sales'], { + required_error: "카테고리를 선택해주세요", + invalid_type_error: "올바른 카테고리를 선택해주세요" + }) + }); +// Q&A 수정 스키마 +export const updateQnaSchema = z.object({ + title: z + .string() + .min(1, "제목을 입력해주세요") + .max(255, "제목은 255자 이하로 입력해주세요") + .trim() + .optional(), + content: z + .string() + .min(1, "내용을 입력해주세요") + // .max(10000, "내용은 10000자 이하로 입력해주세요") + .trim() + .optional(), + category: z + .enum(['engineering', 'procurement', 'technical_sales'], { + required_error: "카테고리를 선택해주세요", + invalid_type_error: "올바른 카테고리를 선택해주세요" + }) + .optional(), +}); + +// 답변 생성 스키마 +export const createAnswerSchema = z.object({ + qnaId: z + .number() + .positive("유효하지 않은 질문 ID입니다"), + content: z + .string() + .min(1, "답변 내용을 입력해주세요") + .max(10000, "답변은 10000자 이하로 입력해주세요") + .trim(), +}); + +// 답변 수정 스키마 +export const updateAnswerSchema = z.object({ + content: z + .string() + .min(1, "답변 내용을 입력해주세요") + .max(10000, "답변은 10000자 이하로 입력해주세요") + .trim(), +}); + +// 댓글 생성 스키마 +export const createCommentSchema = z.object({ + answerId: z + .number() + .positive("유효하지 않은 답변 ID입니다"), + content: z + .string() + .min(1, "댓글 내용을 입력해주세요") + .max(1000, "댓글은 1000자 이하로 입력해주세요") + .trim(), + parentCommentId: z + .number() + .positive() + .optional(), +}); + +// 댓글 수정 스키마 +export const updateCommentSchema = z.object({ + content: z + .string() + .min(1, "댓글 내용을 입력해주세요") + .max(1000, "댓글은 1000자 이하로 입력해주세요") + .trim(), +}); + +// Q&A 벌크 액션 스키마 +export const bulkQnaActionSchema = z.object({ + qnaIds: z + .array(z.number().positive()) + .min(1, "선택된 질문이 없습니다"), + action: z.enum([ + "delete", + "restore", + "archive" + ]), +}); + +// Q&A 검색 필터 스키마 (고급 검색용) +export const qnaSearchFilterSchema = z.object({ + title: z.string().optional(), + content: z.string().optional(), + authorName: z.string().optional(), + companyName: z.string().optional(), + authorDomain: z.array(z.enum(["partners", "tech", "admin"])).optional(), + vendorType: z.array(z.enum(["vendor", "techVendor"])).optional(), + hasAnswers: z.enum(["all", "answered", "unanswered"]).optional(), + isPopular: z.boolean().optional(), + createdFrom: z.date().optional(), + createdTo: z.date().optional(), + lastActivityFrom: z.date().optional(), + lastActivityTo: z.date().optional(), + minAnswers: z.number().min(0).optional(), + maxAnswers: z.number().min(0).optional(), + minComments: z.number().min(0).optional(), + maxComments: z.number().min(0).optional(), +}); + +// Q&A 정렬 옵션 스키마 +export const qnaSortSchema = z.object({ + field: z.enum([ + "createdAt", + "updatedAt", + "lastActivityAt", + "title", + "authorName", + "companyName", + "totalAnswers", + "totalComments", + "answerCount", + "isPopular" + ]), + direction: z.enum(["asc", "desc"]), +}); + +// Q&A 내보내기 스키마 +export const exportQnaSchema = z.object({ + format: z.enum(["csv", "excel", "pdf"]), + fields: z.array(z.enum([ + "title", + "content", + "authorName", + "companyName", + "createdAt", + "totalAnswers", + "totalComments", + "lastActivityAt" + ])).default([ + "title", + "authorName", + "companyName", + "createdAt", + "totalAnswers" + ]), + dateRange: z.object({ + from: z.date().optional(), + to: z.date().optional(), + }).optional(), + includeAnswers: z.boolean().default(false), + includeComments: z.boolean().default(false), +}); + +// 타입 내보내기 +export type GetQnaSchema = Awaited<ReturnType<typeof searchParamsQnaCache.parse>>; +export type CreateQnaSchema = z.infer<typeof createQnaSchema>; +export type UpdateQnaSchema = z.infer<typeof updateQnaSchema>; +export type CreateAnswerSchema = z.infer<typeof createAnswerSchema>; +export type UpdateAnswerSchema = z.infer<typeof updateAnswerSchema>; +export type CreateCommentSchema = z.infer<typeof createCommentSchema>; +export type UpdateCommentSchema = z.infer<typeof updateCommentSchema>; +export type BulkQnaActionSchema = z.infer<typeof bulkQnaActionSchema>; +export type QnaSearchFilterSchema = z.infer<typeof qnaSearchFilterSchema>; +export type QnaSortSchema = z.infer<typeof qnaSortSchema>; +export type ExportQnaSchema = z.infer<typeof exportQnaSchema>; + +// Q&A 상태 enum (프론트엔드에서 사용) +export const QNA_STATUS = { + UNANSWERED: "unanswered", + ANSWERED: "answered", + POPULAR: "popular", + ARCHIVED: "archived", +} as const; + +export const QNA_DOMAIN = { + PARTNERS: "partners", + TECH: "tech", + ADMIN: "admin", +} as const; + +export const QNA_VENDOR_TYPE = { + VENDOR: "vendor", + TECH_VENDOR: "techVendor", +} as const; + +// 필터 옵션 (프론트엔드 드롭다운용) +export const QNA_FILTER_OPTIONS = { + hasAnswers: [ + { value: "all", label: "전체" }, + { value: "answered", label: "답변있음" }, + { value: "unanswered", label: "답변없음" }, + ], + authorDomain: [ + { value: "partners", label: "협력업체" }, + { value: "tech", label: "기술업체" }, + { value: "admin", label: "관리자" }, + ], + vendorType: [ + { value: "vendor", label: "일반 벤더" }, + { value: "techVendor", label: "기술 벤더" }, + ], + myQuestions: [ + { value: "all", label: "전체 질문" }, + { value: "mine", label: "내 질문" }, + { value: "others", label: "다른 사람 질문" }, + ], + isPopular: [ + { value: "all", label: "전체" }, + { value: "popular", label: "인기 질문" }, + { value: "normal", label: "일반 질문" }, + ], +} as const; + +// 정렬 옵션 (프론트엔드 드롭다운용) +export const QNA_SORT_OPTIONS = [ + { value: "lastActivityAt", label: "최근 활동순", direction: "desc" }, + { value: "createdAt", label: "최신 등록순", direction: "desc" }, + { value: "createdAt", label: "오래된 순", direction: "asc" }, + { value: "title", label: "제목순", direction: "asc" }, + { value: "totalAnswers", label: "답변 많은 순", direction: "desc" }, + { value: "totalComments", label: "댓글 많은 순", direction: "desc" }, + { value: "authorName", label: "작성자순", direction: "asc" }, + { value: "companyName", label: "회사명순", direction: "asc" }, +] as const; + +// 유틸리티 함수들 +export const qnaValidationUtils = { + /** + * 검색 파라미터 유효성 검사 + */ + validateSearchParams: (params: unknown) => { + try { + return searchParamsQnaCache.parse(params); + } catch (error) { + console.error("Invalid search parameters:", error); + return searchParamsQnaCache.parse({}); // 기본값 반환 + } + }, + + /** + * 날짜 범위 유효성 검사 + */ + validateDateRange: (from?: string, to?: string) => { + if (!from && !to) return { isValid: true }; + + const fromDate = from ? new Date(from) : null; + const toDate = to ? new Date(to) : null; + + if (fromDate && toDate && fromDate > toDate) { + return { + isValid: false, + error: "시작 날짜가 종료 날짜보다 늦을 수 없습니다." + }; + } + + return { isValid: true }; + }, + + /** + * 페이지네이션 범위 계산 + */ + calculatePagination: (page: number, perPage: number, total: number) => { + const totalPages = Math.ceil(total / perPage); + const hasNextPage = page < totalPages; + const hasPrevPage = page > 1; + const startIndex = (page - 1) * perPage + 1; + const endIndex = Math.min(page * perPage, total); + + return { + totalPages, + hasNextPage, + hasPrevPage, + startIndex, + endIndex, + isFirstPage: page === 1, + isLastPage: page === totalPages, + }; + }, + + /** + * 필터 활성 상태 확인 + */ + hasActiveFilters: (params: GetQnaSchema) => { + return !!( + params.search || + params.authorDomain.length > 0 || + params.vendorType.length > 0 || + params.hasAnswers !== "all" || + params.myQuestions !== "all" || + params.isPopular !== "all" || + params.from || + params.to || + params.minAnswers > 0 || + params.maxAnswers < 999 || + params.companyNames.length > 0 + ); + }, +};
\ No newline at end of file |
