diff --git a/src/app/api/resume/route.ts b/src/app/api/resume/route.ts index 8cb83981b..d74b4a35b 100644 --- a/src/app/api/resume/route.ts +++ b/src/app/api/resume/route.ts @@ -3,31 +3,62 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/utils/auth-option'; import { prisma } from '@/lib/prisma'; import { AUTH_MESSAGE, RESUME_MESSAGE } from '@/constants/message-constants'; -import { SEARCH_PARAMS } from '@/constants/resume-constants'; import { ENV } from '@/constants/env-constants'; import { getToken } from 'next-auth/jwt'; +import { sanitizeQueryParams } from '@/utils/sanitize-query-params'; const { NEXTAUTH_SECRET } = ENV; const { EXPIRED_TOKEN } = AUTH_MESSAGE.ERROR; const { AUTH_REQUIRED } = AUTH_MESSAGE.RESULT; -const { NOT_FOUND, GET_SERVER_ERROR } = RESUME_MESSAGE; -const { STATUS } = SEARCH_PARAMS; +const { GET_SERVER_ERROR } = RESUME_MESSAGE; export const GET = async (request: NextRequest) => { try { const token = await getToken({ req: request, secret: NEXTAUTH_SECRET }); if (!token) return NextResponse.json({ message: EXPIRED_TOKEN }, { status: 401 }); - const session = await getServerSession(authOptions); if (!session || !session.user) { return NextResponse.json({ message: AUTH_REQUIRED }, { status: 401 }); } + const userId = session.user.id; + const searchParams = request.nextUrl.searchParams; + + const { + page = undefined, + limit = undefined, + status = undefined, + reqType = undefined, + } = sanitizeQueryParams(searchParams); - const status = request.nextUrl.searchParams.get(STATUS); + if (reqType === 'infinity') { + const pageNumber = Number(page); + const limitNumber = Number(limit); + + if (isNaN(pageNumber) || isNaN(limitNumber)) { + return NextResponse.json({ message: '유효하지 않은 파라미터입니다.' }, { status: 400 }); + } + const response = await prisma.resume.findMany({ + where: { + userId: userId, + status: Number(status), + }, + orderBy: { + createdAt: 'desc', + }, + skip: (pageNumber - 1) * limitNumber, + take: limitNumber, + }); + + const totalCount = await prisma.resume.count({ + where: { userId, status: Number(status) }, + }); + const nextPage = pageNumber * limitNumber < totalCount ? pageNumber + 1 : null; + return NextResponse.json({ response, nextPage }, { status: 200 }); + } const response = await prisma.resume.findMany({ where: { - userId: session.user.id, + userId: userId, status: Number(status), }, orderBy: { @@ -35,10 +66,6 @@ export const GET = async (request: NextRequest) => { }, }); - if (!response) { - return NextResponse.json({ message: NOT_FOUND }, { status: 404 }); - } - return NextResponse.json({ response }, { status: 200 }); } catch (error) { return NextResponse.json({ message: GET_SERVER_ERROR }, { status: 500 }); diff --git a/src/constants/resume-constants.ts b/src/constants/resume-constants.ts index fdbcffbbf..f1ebe6717 100644 --- a/src/constants/resume-constants.ts +++ b/src/constants/resume-constants.ts @@ -1,7 +1,7 @@ export const RESUME_STATUS = { DRAFT: 0, SUBMIT: 1, -}; +} as const; export const AUTO_SAVE_STATUS = { SAVING: '자동 저장 중', diff --git a/src/features/interview/resume-all-modal.tsx b/src/features/interview/resume-all-modal.tsx index c8104aced..c8d195f15 100644 --- a/src/features/interview/resume-all-modal.tsx +++ b/src/features/interview/resume-all-modal.tsx @@ -58,7 +58,6 @@ const ResumeAllModal = () => { setResume(resume.id); toggleModal(ALL_RESUME_LIST); }} - hrOption={false} /> ); })} diff --git a/src/features/resume-list/hooks/use-resume-infinite-query.ts b/src/features/resume-list/hooks/use-resume-infinite-query.ts new file mode 100644 index 000000000..74363a87b --- /dev/null +++ b/src/features/resume-list/hooks/use-resume-infinite-query.ts @@ -0,0 +1,27 @@ +import { QUERY_KEY } from '@/constants/query-key'; +import { RESUME_STATUS } from '@/constants/resume-constants'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { getResumeListByInfinite } from '@/features/resume/api/client-services'; + +const { RESUMES } = QUERY_KEY; + +const ITEM_PER_PAGE = 8; +export const useResumeInfiniteQuery = (status: (typeof RESUME_STATUS)[keyof typeof RESUME_STATUS]) => { + return useInfiniteQuery({ + queryKey: [RESUMES, 'infinity'], + queryFn: async ({ pageParam = 1 }) => { + const params = { + status, + pageParam, + limit: ITEM_PER_PAGE, + reqType: 'infinity', + }; + + const response = await getResumeListByInfinite(params); + return response; + }, + getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, + initialPageParam: 1, + refetchIntervalInBackground: false, + }); +}; diff --git a/src/features/resume-list/resume-item.tsx b/src/features/resume-list/resume-item.tsx index a8fcdc13d..e8868cc34 100644 --- a/src/features/resume-list/resume-item.tsx +++ b/src/features/resume-list/resume-item.tsx @@ -6,26 +6,26 @@ import clsx from 'clsx'; type Props = { resume: ResumeType; onClick: (resumeId: ResumeType['id']) => void; - hrOption?: boolean; + isLastChild?: boolean; }; -const ResumeItem = ({ resume, onClick, hrOption = true }: Props) => { +const ResumeItem = ({ resume, onClick, isLastChild = false }: Props) => { const { id, title, createdAt, tryCount } = resume; const hasNotInterviewed = tryCount === 0; return ( -
  • onClick(id)} className={clsx(hrOption && 'border-b', 'cursor-pointer py-2')}> +
  • onClick(id)} className={clsx('cursor-pointer py-2', isLastChild ? 'border-b-0' : 'border-b')}> {formatDate({ input: createdAt })}
    {title} {hasNotInterviewed ? ( - + 면접 보기 전 ) : ( - + {tryCount}회 면접 완료 )} diff --git a/src/features/resume-list/resume-list.tsx b/src/features/resume-list/resume-list.tsx index e5784f798..8de7b2300 100644 --- a/src/features/resume-list/resume-list.tsx +++ b/src/features/resume-list/resume-list.tsx @@ -1,17 +1,22 @@ 'use client'; -import { useRouter } from 'next/navigation'; import LoadingAnimation from '@/components/common/loading-animation'; -import ResumeItem from '@/features/resume-list/resume-item'; -import { useResumeListQuery } from '@/features/resume-list/hooks/use-resume-list-query'; -import { getMyPagePath } from '@/features/my-page/utils/get-my-page-path'; import { TABS } from '@/constants/my-page-constants'; +import { RESUME_STATUS } from '@/constants/resume-constants'; +import { getMyPagePath } from '@/features/my-page/utils/get-my-page-path'; +import { useResumeInfiniteQuery } from '@/features/resume-list/hooks/use-resume-infinite-query'; +import ResumeItem from '@/features/resume-list/resume-item'; +import { useInfiniteScroll } from '@/hooks/customs/use-infinite-scroll'; +import { useRouter } from 'next/navigation'; const { RESUME_TAB } = TABS; - +const { SUBMIT } = RESUME_STATUS; const ResumeList = () => { const router = useRouter(); - - const { data: resumeList, isPending, isError } = useResumeListQuery(); + const { data, isPending, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useResumeInfiniteQuery(SUBMIT); + const targetRef = useInfiniteScroll({ + fetchNextPage, + hasNextPage, + }); const handleGetDetailList = (resumeId: number) => { router.push(getMyPagePath(RESUME_TAB, resumeId)); @@ -27,18 +32,23 @@ const ResumeList = () => { if (isError) return
    자소서 리스트를 불러오는데 실패하였습니다.
    ; + const resumes = data.pages.flatMap((page) => page.response); + return (
      - {resumeList.map((resume, index) => { + {resumes?.map((resume, index) => { return ( ); })} +
      + {isFetchingNextPage && 로딩 중..} +
    ); }; diff --git a/src/features/resume/api/client-services.ts b/src/features/resume/api/client-services.ts index 71f8a8501..054859648 100644 --- a/src/features/resume/api/client-services.ts +++ b/src/features/resume/api/client-services.ts @@ -2,7 +2,7 @@ import { API_HEADER, API_METHOD } from '@/constants/api-method-constants'; import { ROUTE_HANDLER_PATH } from '@/constants/path-constant'; import type { ResumeData } from '@/types/resume'; import { fetchWithSentry } from '@/utils/fetch-with-sentry'; -import type { Resume } from '@prisma/client'; +import { ResumeType } from '@/types/DTO/resume-dto'; type Props = { data: ResumeData; @@ -14,7 +14,7 @@ const { GET, POST, PATCH, DELETE } = API_METHOD; const { JSON_HEADER } = API_HEADER; type SubmitParams = { - resumeId: Resume['id']; + resumeId: ResumeType['id']; type: typeof POST | typeof PATCH; }; /** @@ -64,7 +64,7 @@ export const autoSaveResume = async ({ resumeId, data }: Props) => { * @param {Number} status 저장 상태(등록/임시 저장) * @returns resumes 상태에 따른 자소서 리스트 */ -export const getResumeList = async (status: number): Promise => { +export const getResumeList = async (status: number): Promise => { const url = `${ROOT}?status=${status}`; const { response: resumeList } = await fetchWithSentry(url, { @@ -78,42 +78,37 @@ type ResumeListProps = { status: number; pageParam: number; limit: number; + reqType: string; }; -type PaginatedResumeListResponse = { - resumeList: Resume[]; - nextPage: number | null; -}; /** - * - * @param {Number} status 저장 상태(등록/임시 저장) - * @param {Number} pageParam 가져올 페이지 번호 - * @param {Number} limit 한 페이지에 표시할 항목 수 - * @returns resumeList 자소서 목록 - * @returns nextPage 다음 페이지 + * 특정 개수 단위로 자소서 data를 서버에서 받아옴 + * @param params - { status : 받아올 자소서의 상태 , pageParams : 초기 page, limit : 받아올 데이터의 개수 } + * @returns page,다음 page, 해당 page에 들어있는 data */ -export const getPaginatedResumeList = async ({ - status, - pageParam, - limit, -}: ResumeListProps): Promise => { - const url = `${ROOT}?status=${status}&page=${pageParam}&limit=${limit}`; +export const getResumeListByInfinite = async (params: ResumeListProps) => { + const { pageParam, limit, status, reqType } = params; + const queryParams = new URLSearchParams({ + page: pageParam.toString(), + limit: limit.toString(), + status: status.toString(), + reqType, + }); - const { response: resumeList } = await fetchWithSentry(url, { + const url = `${ROOT}?${queryParams}`; + + const data: { response: ResumeType[]; nextPage: number | null } = await fetchWithSentry(url, { method: GET, }); - const hasNextPage = resumeList.length === limit; - const nextPage = hasNextPage ? pageParam + 1 : null; - - return { resumeList, nextPage }; + return data; }; /** * DB에서 원하는 자소서를 불러오는 요청 * @returns {Array} draftResumes 임시 저장된 자소서 리스트 */ -export const getResume = async (resumeId: number): Promise => { +export const getResume = async (resumeId: number): Promise => { const { response: resume } = await fetchWithSentry(DETAIL(resumeId), { method: GET, });