diff --git a/.github/workflows/deploy-nest-dev.yml b/.github/workflows/deploy-nest-dev.yml index b76872f3..d2ca1fa8 100644 --- a/.github/workflows/deploy-nest-dev.yml +++ b/.github/workflows/deploy-nest-dev.yml @@ -2,7 +2,7 @@ name: Deployment Nestjs server (Dev) on: push: - branches: [development] + branches: [development-disabled-temporarily] jobs: build-and-deploy: diff --git a/.github/workflows/deploy-nest-prod.yml b/.github/workflows/deploy-nest-prod.yml index aad79ab3..c9134b74 100644 --- a/.github/workflows/deploy-nest-prod.yml +++ b/.github/workflows/deploy-nest-prod.yml @@ -2,7 +2,7 @@ name: Deployment Nestjs server (Prod) on: push: - branches: [main] + branches: [main-disabled-temporarily] jobs: build-and-deploy: diff --git a/.gitignore b/.gitignore index 5f86552f..5d0d55fc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ node_modules/ .tanstack *.pem *.key -*.crt \ No newline at end of file +*.crt +robots.txt +sitemap.xml \ No newline at end of file diff --git a/apps/client/src/domains/interview/components/interviewAnswerForm.tsx b/apps/client/src/domains/interview/components/interviewAnswerForm.tsx index e4f2cf11..8eebf350 100644 --- a/apps/client/src/domains/interview/components/interviewAnswerForm.tsx +++ b/apps/client/src/domains/interview/components/interviewAnswerForm.tsx @@ -8,7 +8,6 @@ import { InterviewAnswerForm as InterviewAnswerFormType } from "@kokomen/types"; import { useSpeechRecognitionWithEvents } from "@/domains/interview/hooks/useSpeechRecognitionWithEvents"; -import { interviewEventHelpers } from "@/domains/interview/utils/interviewEventEmitter"; import type { InterviewerEmotion } from "@/pages/interviews/[interviewId]"; import { captureFormSubmitEvent } from "@/utils/analytics"; import { Button, LoadingCircles, Textarea } from "@kokomen/ui"; @@ -16,6 +15,7 @@ import { getEmotion } from "@kokomen/utils"; import { useMutation } from "@tanstack/react-query"; import { ArrowBigUp, CircleStop, Mic } from "lucide-react"; import React, { JSX, MouseEvent, useCallback, useRef, useState } from "react"; +import { publishInterviewEvent } from "@/domains/interview/utils/interviewEventEmitter"; type InterviewInputProps = Pick< Interview, @@ -85,7 +85,7 @@ export function InterviewAnswerForm({ ); }, onMutate: (data) => { - interviewEventHelpers.stopVoiceRecognition(); + publishInterviewEvent("stopVoiceRecognition"); captureFormSubmitEvent({ name: "submitInterviewAnswer", properties: { @@ -294,7 +294,7 @@ function VoiceInputButton({ name="interview-voice-stop" variant={"glass"} className="flex items-center gap-2 text-text-tertiary" - onClick={interviewEventHelpers.stopVoiceRecognition} + onClick={() => publishInterviewEvent("stopVoiceRecognition")} disabled={disabled} > publishInterviewEvent("startVoiceRecognition")} disabled={disabled} > diff --git a/apps/client/src/domains/interview/hooks/useSpeechRecognitionWithEvents.ts b/apps/client/src/domains/interview/hooks/useSpeechRecognitionWithEvents.ts index 25658f03..b8647eb6 100644 --- a/apps/client/src/domains/interview/hooks/useSpeechRecognitionWithEvents.ts +++ b/apps/client/src/domains/interview/hooks/useSpeechRecognitionWithEvents.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useInterviewEvent, - interviewEventHelpers + publishInterviewEvent } from "@/domains/interview/utils/interviewEventEmitter"; import { InterviewMode } from "@kokomen/types"; @@ -57,20 +57,20 @@ export const useSpeechRecognitionWithEvents = ({ const handleSpeechStart = useCallback((): void => { setIsListening(true); setError(null); - interviewEventHelpers.notifyVoiceStarted(); + publishInterviewEvent("interview:voiceRecognitionStarted"); }, []); const handleSpeechEnd = useCallback((): void => { setIsListening(false); - interviewEventHelpers.notifyVoiceStopped(); + publishInterviewEvent("interview:voiceRecognitionStopped"); if (result.current[resultPointer.current] === "") { return; } if (mode === "VOICE") { - interviewEventHelpers.stopVoiceRecognition(); + publishInterviewEvent("interview:stopVoiceRecognition"); setTimeout(() => { - interviewEventHelpers.startVoiceRecognition(); + publishInterviewEvent("interview:startVoiceRecognition"); }, 500); } resultPointer.current++; @@ -89,7 +89,9 @@ export const useSpeechRecognitionWithEvents = ({ result.current[resultPointer.current] = resultString; const fullResult = result.current.join(" "); onSpeechEnd(fullResult); - interviewEventHelpers.sendVoiceResult(fullResult); + publishInterviewEvent("interview:voiceRecognitionResult", { + text: fullResult + }); }, [onSpeechEnd, enabled] ); @@ -122,11 +124,13 @@ export const useSpeechRecognitionWithEvents = ({ } setError(errorMessage); - interviewEventHelpers.notifyVoiceError(errorMessage); + publishInterviewEvent("interview:voiceRecognitionError", { + error: errorMessage + }); if (mode === "VOICE") { - interviewEventHelpers.stopVoiceRecognition(); + publishInterviewEvent("interview:stopVoiceRecognition"); setTimeout(() => { - interviewEventHelpers.startVoiceRecognition(); + publishInterviewEvent("interview:startVoiceRecognition"); }, 2000); } setTimeout(() => { @@ -187,7 +191,9 @@ export const useSpeechRecognitionWithEvents = ({ if (!isSupported) { const errorMsg = "음성 인식이 지원되지 않습니다."; setError(errorMsg); - interviewEventHelpers.notifyVoiceError(errorMsg); + publishInterviewEvent("interview:voiceRecognitionError", { + error: errorMsg + }); return; } @@ -204,7 +210,9 @@ export const useSpeechRecognitionWithEvents = ({ } catch (error) { const errorMsg = "음성 인식을 시작할 수 없습니다."; setError(errorMsg); - interviewEventHelpers.notifyVoiceError(errorMsg); + publishInterviewEvent("interview:voiceRecognitionError", { + error: errorMsg + }); } }, [isSupported, createSpeechRecognition, detachEventListeners]); @@ -226,7 +234,7 @@ export const useSpeechRecognitionWithEvents = ({ // 이벤트 구독 - 음성 인식 시작 요청 useInterviewEvent( - "startVoiceRecognition", + "interview:startVoiceRecognition", () => { startListening(); }, @@ -235,7 +243,7 @@ export const useSpeechRecognitionWithEvents = ({ // 이벤트 구독 - 음성 인식 중지 요청 useInterviewEvent( - "stopVoiceRecognition", + "interview:stopVoiceRecognition", () => { stopListening(); }, @@ -251,7 +259,9 @@ export const useSpeechRecognitionWithEvents = ({ setIsSupported(false); const errorMsg = "이 브라우저는 음성 인식을 지원하지 않습니다."; setError(errorMsg); - interviewEventHelpers.notifyVoiceError(errorMsg); + publishInterviewEvent("interview:voiceRecognitionError", { + error: errorMsg + }); return; } diff --git a/apps/client/src/domains/interview/utils/interviewEventEmitter.ts b/apps/client/src/domains/interview/utils/interviewEventEmitter.ts index d2b5850f..aa173719 100644 --- a/apps/client/src/domains/interview/utils/interviewEventEmitter.ts +++ b/apps/client/src/domains/interview/utils/interviewEventEmitter.ts @@ -1,66 +1,8 @@ /* eslint-disable no-unused-vars */ -import { EventEmitter } from "events"; -import { DependencyList, useEffect } from "react"; - -export type InterviewEventType = - | "startVoiceRecognition" - | "stopVoiceRecognition" - | "voiceRecognitionStarted" - | "voiceRecognitionStopped" - | "voiceRecognitionError" - | "voiceRecognitionResult"; - -interface InterviewEventPayloads { - startVoiceRecognition: undefined; - stopVoiceRecognition: undefined; - voiceRecognitionStarted: undefined; - voiceRecognitionStopped: undefined; - voiceRecognitionError: { error: string }; - voiceRecognitionResult: { text: string }; -} - -class TypedEventEmitter extends EventEmitter { - public emit( - event: K, - ...args: InterviewEventPayloads[K] extends undefined - ? [] - : [InterviewEventPayloads[K]] - ): boolean { - return super.emit(event, ...args); - } - - public on( - event: K, - listener: InterviewEventPayloads[K] extends undefined - ? () => void - : (payload: InterviewEventPayloads[K]) => void - ): this { - return super.on(event, listener); - } - - public off( - event: K, - listener: InterviewEventPayloads[K] extends undefined - ? () => void - : (payload: InterviewEventPayloads[K]) => void - ): this { - return super.off(event, listener); - } - - public once( - event: K, - listener: InterviewEventPayloads[K] extends undefined - ? () => void - : (payload: InterviewEventPayloads[K]) => void - ): this { - return super.once(event, listener); - } -} - -// 싱글톤 인스턴스 -export const interviewEvents: TypedEventEmitter = new TypedEventEmitter(); - -// React Hook for subscribing to events +import { publishEvent, useSubscribeEvents } from "@/utils/eventEmitter"; +import { InterviewEventPayloads, InterviewEventType } from "@kokomen/types"; +import { DependencyList } from "react"; +// 이벤트에 대서 콜백 함수 구독하는 훅 export function useInterviewEvent( event: K, handler: InterviewEventPayloads[K] extends undefined @@ -68,46 +10,13 @@ export function useInterviewEvent( : (payload: InterviewEventPayloads[K]) => void, deps: DependencyList = [] ): void { - useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - interviewEvents.on(event, handler as any); - return () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - interviewEvents.off(event, handler as any); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [event, ...deps]); + const eventEmitter = useSubscribeEvents( + [{ event, handler }], + [] + ); } -// 이벤트 발행 헬퍼 함수들 -export const interviewEventHelpers: { - startVoiceRecognition: () => void; - stopVoiceRecognition: () => void; - notifyVoiceStarted: () => void; - notifyVoiceStopped: () => void; - notifyVoiceError: (error: string) => void; - sendVoiceResult: (text: string) => void; -} = { - startVoiceRecognition: (): void => { - interviewEvents.emit("startVoiceRecognition"); - }, - stopVoiceRecognition: (): void => { - interviewEvents.emit("stopVoiceRecognition"); - }, - - notifyVoiceStarted: (): void => { - interviewEvents.emit("voiceRecognitionStarted"); - }, - - notifyVoiceStopped: (): void => { - interviewEvents.emit("voiceRecognitionStopped"); - }, - - notifyVoiceError: (error: string): void => { - interviewEvents.emit("voiceRecognitionError", { error }); - }, - - sendVoiceResult: (text: string): void => { - interviewEvents.emit("voiceRecognitionResult", { text }); - } -}; +export const publishInterviewEvent = publishEvent< + InterviewEventType, + InterviewEventPayloads +>(); diff --git a/apps/client/src/domains/resume/api/archive.ts b/apps/client/src/domains/resume/api/archive.ts new file mode 100644 index 00000000..645550e6 --- /dev/null +++ b/apps/client/src/domains/resume/api/archive.ts @@ -0,0 +1,21 @@ +import { mapToCamelCase } from "@/utils/convertConvention"; +import { ArchivedResumeAndPortfolio } from "@kokomen/types"; +import axios from "axios"; + +const archiveServerInstance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL + "/resumes", + withCredentials: true +}); + +export const getArchivedResumes = (type?: "ALL" | "RESUME" | "PORTFOLIO") => { + return archiveServerInstance + .get<{ + resumes: ArchivedResumeAndPortfolio[]; + portfolios: ArchivedResumeAndPortfolio[]; + }>("", { + params: { + type: type ?? "ALL" + } + }) + .then((res) => mapToCamelCase(res.data)); +}; diff --git a/apps/client/src/domains/resume/api/index.ts b/apps/client/src/domains/resume/api/index.ts index b8d7b3df..3ef35182 100644 --- a/apps/client/src/domains/resume/api/index.ts +++ b/apps/client/src/domains/resume/api/index.ts @@ -1,17 +1,155 @@ import { mapToCamelCase } from "@/utils/convertConvention"; -import { ResumeInput, ResumeOutput } from "@kokomen/types"; -import axios from "axios"; +import { + ResumeEvaluationResult, + ResumeFailed, + ResumeOutput, + ResumePending +} from "@kokomen/types"; +import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; +import { delay, exponentialDelay } from "@kokomen/utils"; +import { GetServerSidePropsContext } from "next"; +// 요청별 재시도 상태를 관리하는 Map +const retryStateMap = new Map(); + +// 요청 식별자 생성 함수 +const createRequestId = (config: AxiosRequestConfig): string => { + const { method, url, data } = config; + return `${method}:${url}:${JSON.stringify(data || {})}`; +}; + +// 재시도 상태 관리 함수들 +const getRetryCount = (requestId: string): number => { + return retryStateMap.get(requestId) || 0; +}; + +const incrementRetryCount = (requestId: string): number => { + const currentCount = getRetryCount(requestId); + const newCount = currentCount + 1; + retryStateMap.set(requestId, newCount); + return newCount; +}; + +const resetRetryCount = (requestId: string): void => { + retryStateMap.delete(requestId); +}; + +// 이력서 제출 부분 const resumeServerInstance = axios.create({ - baseURL: process.env.NEXT_PUBLIC_V3_API_BASE_URL + "/resume", + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL + "/resumes", withCredentials: true }); +resumeServerInstance.interceptors.response.use( + (response: AxiosResponse) => { + // 성공 시 해당 요청의 retry 상태 정리 + const requestId = createRequestId(response.config); + resetRetryCount(requestId); + return response; + }, + + // 에러 응답 처리 + async (error: AxiosError) => { + const requestId = createRequestId(error.config as AxiosRequestConfig); + const retryCount = incrementRetryCount(requestId); + const maxRetries = 3; + + if (retryCount >= maxRetries) { + resetRetryCount(requestId); + return Promise.reject(error); + } + + await exponentialDelay(retryCount); + return resumeServerInstance.request(error.config as AxiosRequestConfig); + } +); -function submitResumeEvaluation(data: ResumeInput) { +function submitResumeEvaluation(data: FormData) { return resumeServerInstance - .post("/evaluation", data) + .post<{ evaluation_id: string }>("/evaluations", data) .then((res) => res.data) .then(mapToCamelCase); } -export { submitResumeEvaluation }; +const resumePollingServerInstance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL + "/resumes/evaluations", + withCredentials: true +}); + +// 폴링에 대한 요청 완료시 +const onFullFilledPolling = async ( + response: AxiosResponse +) => { + const requestId = createRequestId(response.config); + + if (response.data.state === "COMPLETED") { + resetRetryCount(requestId); + return response; + } + + if (response.data.state === "FAILED") + return Promise.reject("이력서 평가 중 오류가 발생했어요"); + + const retryCount = incrementRetryCount(requestId); + const maxRetries = 50; + + if (retryCount >= maxRetries) { + resetRetryCount(requestId); + return Promise.reject("서버가 응답하지 않습니다."); + } + + await delay(1000); + return resumePollingServerInstance.request(response.config); +}; + +// 폴링 에러 처리 함수 +const onRejectedPolling = async (error: AxiosError) => { + const requestId = createRequestId(error.config as AxiosRequestConfig); + const retryCount = incrementRetryCount(requestId); + const maxRetries = 3; + + if (retryCount >= maxRetries) { + resetRetryCount(requestId); + return Promise.reject(error); + } + + await exponentialDelay(retryCount); + return resumeServerInstance.request(error.config as AxiosRequestConfig); +}; + +// 인터뷰 면접 답변 폴링을 위한 서버 인스턴스 +resumePollingServerInstance.interceptors.response.use( + onFullFilledPolling, + onRejectedPolling +); + +function getResumeEvaluationState(evaluationId: string) { + return resumePollingServerInstance + .get(`/${evaluationId}/state`) + .then((res) => res.data) + .then(mapToCamelCase); +} + +const resumeResultServerInstance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL + "/resumes/evaluations", + withCredentials: true +}); + +function getResumeEvaluationResult( + evaluationId: string, + context: GetServerSidePropsContext +) { + return resumeResultServerInstance + .get(`/${evaluationId}`, { + headers: { + Cookie: context.req.headers.cookie + } + }) + .then((res) => res.data) + .then(mapToCamelCase); +} +export { + submitResumeEvaluation, + getResumeEvaluationState, + getResumeEvaluationResult +}; +export * from "./archive"; diff --git a/apps/client/src/domains/resume/components/index.ts b/apps/client/src/domains/resume/components/index.ts index 830a4a45..26855e67 100644 --- a/apps/client/src/domains/resume/components/index.ts +++ b/apps/client/src/domains/resume/components/index.ts @@ -2,3 +2,4 @@ export { default as ResumeSelectMenu3d } from "./resumeSelectMenu3d"; export { default as ResumeSelectMenuNormal } from "./resumeSelectMenuNormal"; export { default as ResumeEvaluationResult } from "./resumeEvaluationResult"; export { default as ResumeEvaluationDemoForm } from "./resumeEvaluationForm.demo"; +export * from "./resumeArchiveButton"; diff --git a/apps/client/src/domains/resume/components/resumeArchiveButton.tsx b/apps/client/src/domains/resume/components/resumeArchiveButton.tsx new file mode 100644 index 00000000..0a51289b --- /dev/null +++ b/apps/client/src/domains/resume/components/resumeArchiveButton.tsx @@ -0,0 +1,176 @@ +import { Button, Sidebar } from "@kokomen/ui"; +import { Layers, PackageOpen, X } from "lucide-react"; +import { useSidebar } from "@kokomen/utils"; +import { + ArchivedResumeAndPortfolio, + CamelCasedProperties +} from "@kokomen/types"; +import { formatDate } from "@/utils/date"; +import { useQuery } from "@tanstack/react-query"; +import { archiveKeys } from "@/utils/querykeys"; +import { getArchivedResumes } from "@/domains/resume/api"; + +function ArchiveButton({ + type = "ALL", + onClickResume, + isLoggedIn +}: { + type: "ALL" | "RESUME" | "PORTFOLIO"; + // eslint-disable-next-line no-unused-vars + onClickResume: (data: { + resume_id?: string; + resume_name?: string; + portfolio_id?: string; + portfolio_name?: string; + }) => void; + isLoggedIn: boolean; +}) { + const { open, openSidebar, closeSidebar } = useSidebar(); + const handleClick = () => { + openSidebar(); + }; + return ( + <> + + +
+ +
+
+

+ 이력서 및 포트폴리오 가져오기 +

+

+ 이력서를 업로드하면 자동으로 아카이빙돼요. +

+
+ +
+
+
+ + ); +} + +function ArchiveItem({ + title, + createdAt, + onClick +}: CamelCasedProperties & { onClick: () => void }) { + return ( + + ); +} + +function ArchiveListEmpty({ type }: { type: "ALL" | "RESUME" | "PORTFOLIO" }) { + return ( +
+ +

+ 아직 보관된 {type === "RESUME" ? "이력서" : "포트폴리오"}가 없어요. +

+
+ ); +} + +function ArchiveList({ + type = "ALL", + isLoggedIn, + onClickResume, + closeSidebar +}: { + type: "ALL" | "RESUME" | "PORTFOLIO"; + isLoggedIn: boolean; + // eslint-disable-next-line no-unused-vars + onClickResume: (data: { + resume_id?: string; + resume_name?: string; + portfolio_id?: string; + portfolio_name?: string; + }) => void; + closeSidebar: () => void; +}) { + const { data } = useQuery({ + queryKey: archiveKeys.resumes(type), + queryFn: () => getArchivedResumes(type), + enabled: isLoggedIn, + select: (data) => data, + staleTime: 1000 * 60 * 60, + gcTime: 1000 * 60 * 5 + }); + const list = + type === "ALL" + ? data?.resumes + : type === "RESUME" + ? data?.resumes + : data?.portfolios; + + const onclickArchivedItem = ( + type: "RESUME" | "PORTFOLIO", + item: CamelCasedProperties + ) => { + if (type === "RESUME") { + onClickResume({ + resume_id: item.id.toString(), + resume_name: item.title + }); + } else if (type === "PORTFOLIO") { + onClickResume({ + portfolio_id: item.id.toString(), + portfolio_name: item.title + }); + } + }; + return ( +
+
+

+ {type === "ALL" + ? "이력서" + : type === "RESUME" + ? "이력서" + : "포트폴리오"} +

+ {list?.map((item) => ( + { + onclickArchivedItem( + type === "ALL" + ? "RESUME" + : type === "RESUME" + ? "RESUME" + : "PORTFOLIO", + item + ); + closeSidebar(); + }} + /> + ))} + {list?.length === 0 && } +
+
+ ); +} + +export { ArchiveButton }; diff --git a/apps/client/src/domains/resume/components/resumeEvaluationForm.tsx b/apps/client/src/domains/resume/components/resumeEvaluationForm.tsx index 4eab6b22..fe9021db 100644 --- a/apps/client/src/domains/resume/components/resumeEvaluationForm.tsx +++ b/apps/client/src/domains/resume/components/resumeEvaluationForm.tsx @@ -1,56 +1,50 @@ import { submitResumeEvaluation } from "@/domains/resume/api"; +import { ArchiveButton } from "@/domains/resume/components/resumeArchiveButton"; import useExtendedRouter from "@/hooks/useExtendedRouter"; -// import { resumeEvaluationDemoResult } from "@/domains/resume/constants"; import { withApiErrorCapture } from "@/utils/error"; -import { parsePdf } from "@/utils/pdf"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; -import { - CamelCasedProperties, - ResumeInput, - ResumeOutput -} from "@kokomen/types"; +import { generateFormData } from "@kokomen/utils"; +import { CamelCasedProperties, UserInfo } from "@kokomen/types"; import { Button, FileField, Input, useToast } from "@kokomen/ui"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { isAxiosError } from "axios"; -import Image from "next/image"; -import { Dispatch, SetStateAction, useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import z from "zod"; - -const ResumeEvaluationLoading = () => ( -
- kokomenReport -
-

보고서를 생성하는 중이에요. 잠시만 기다려주세요.

-

최대 1분까지 소요될 수 있어요

-
-
-); +import { archiveKeys } from "@/utils/querykeys"; +import { publishReportEvent } from "@/domains/resume/utils/reportEventEmitter"; const jobCareers = ["0-1년", "1-3년", "3-5년", "5-10년", "10년 이상"]; -const resumeEvalFormFields = z.object({ - resume: z.instanceof(FileList).refine((fileList) => fileList.length > 0, { - message: "이력서를 업로드해주세요" - }), - portfolio: z.instanceof(FileList).optional(), - job_position: z.string().nonempty({ message: "지원 직무를 입력해주세요" }), - job_description: z.string().optional(), - job_career: z.enum(jobCareers as [string, ...string[]]).default("0-1년") -}); +const resumeEvalFormFields = z + .object({ + // FileList를 직접 받거나, 이미 업로드된 경우를 위해 optional 처리 + resume: z.instanceof(FileList).optional(), + resume_id: z.string().optional(), + + portfolio: z.instanceof(FileList).optional(), + portfolio_id: z.string().optional(), + + job_position: z.string().min(1, { message: "지원 직무를 입력해주세요" }), + job_description: z.string().optional(), + job_career: z.enum(jobCareers as [string, ...string[]]).default("0-1년") + }) + // 1. 이력서 검증: ID가 있거나, 파일이 선택되었거나 + .refine((data) => data.resume_id || (data.resume && data.resume.length > 0), { + message: "이력서를 선택해주세요", + path: ["resume"] // 에러 메시지를 표시할 필드 위치 + }) + // 2. 포트폴리오 검증: ID가 있거나, 파일이 선택되었거나 + .refine( + (data) => + data.portfolio_id || (data.portfolio && data.portfolio.length > 0), + { + message: "포트폴리오를 선택해주세요", + path: ["portfolio"] + } + ); type ResumeEvalFormFields = z.infer; -export default function ResumeEvaluationForm({ - setResult -}: { - setResult: Dispatch< - SetStateAction | null> - >; -}) { +export default function ResumeEvaluationForm({ user }: { user: UserInfo }) { const { toast } = useToast(); const form = useForm({ resolver: standardSchemaResolver(resumeEvalFormFields), @@ -58,16 +52,44 @@ export default function ResumeEvaluationForm({ job_career: "0-1년" } }); + const [displayName, setDisplayName] = useState<{ + resume: string; + portfolio: string; + }>({ resume: "", portfolio: "" }); + + useEffect(() => { + const resume = form.getValues("resume"); + const portfolio = form.getValues("portfolio"); + if (resume instanceof FileList && resume.length > 0) { + setDisplayName({ ...displayName, resume: "" }); + form.setValue("resume_id", ""); + } + if (portfolio instanceof FileList && portfolio.length > 0) { + setDisplayName({ ...displayName, portfolio: "" }); + form.setValue("portfolio_id", ""); + } + }, [form.watch("resume_id"), form.watch("portfolio_id")]); + + const queryClient = useQueryClient(); const router = useExtendedRouter(); const mutation = useMutation< - CamelCasedProperties, + CamelCasedProperties<{ evaluation_id: string }>, Error, - ResumeInput + FormData >({ - mutationFn: (data) => submitResumeEvaluation(data), + mutationFn: submitResumeEvaluation, onSuccess: (data) => { - form.reset(); - setResult(data); + queryClient.invalidateQueries({ queryKey: archiveKeys.resumes("ALL") }); + publishReportEvent("report:submitted", { + evaluation_id: data.evaluationId + }); + toast({ + title: "이력서 분석 중입니다. 잠시 후 평가 결과를 알려드려요", + variant: "info" + }); + router.replace({ + pathname: "/resume" + }); }, onError: withApiErrorCapture((error) => { if (isAxiosError(error) && error.response?.status === 401) { @@ -88,32 +110,35 @@ export default function ResumeEvaluationForm({ async function onSubmit(data: ResumeEvalFormFields) { try { setIsParsing(true); - let resume = ""; - let portfolio = ""; - if (data.portfolio && data.portfolio?.length > 0) { - const parsedFiles = await parsePdf([ - data.resume[0], - data.portfolio[0] - ] as File[]); - resume = parsedFiles[0]; - portfolio = parsedFiles[1]; - } else { - resume = await parsePdf(data.resume[0] as File); - } - - mutation.mutate({ - resume: resume, - portfolio: portfolio, - job_position: data.job_position, - job_description: data.job_description || "", - job_career: data.job_career - }); + const formData = generateFormData(data); + mutation.mutate(formData); } catch (error) { console.log(error); } finally { setIsParsing(false); } } + const onclickArchiveButton = (data: { + resume_id?: string; + resume_name?: string; + portfolio_id?: string; + portfolio_name?: string; + }) => { + if (data.resume_id) { + form.setValue("resume_id", data.resume_id); + setDisplayName({ + ...displayName, + resume: data.resume_name || "" + }); + } + if (data.portfolio_id) { + form.setValue("portfolio_id", data.portfolio_id); + setDisplayName({ + ...displayName, + portfolio: data.portfolio_name || "" + }); + } + }; const isPending = isParsing || mutation.isPending; @@ -130,19 +155,35 @@ export default function ResumeEvaluationForm({
- +
+ + +
- +
+ + +
- {isPending && }
); } diff --git a/apps/client/src/domains/resume/components/resumeEvaluationResult.tsx b/apps/client/src/domains/resume/components/resumeEvaluationResult.tsx index c314f135..95401f2f 100644 --- a/apps/client/src/domains/resume/components/resumeEvaluationResult.tsx +++ b/apps/client/src/domains/resume/components/resumeEvaluationResult.tsx @@ -1,4 +1,7 @@ -import { CamelCasedProperties, ResumeOutput } from "@kokomen/types"; +import { + CamelCasedProperties, + ResumeEvaluationResult as ResumeEvaluationResultType +} from "@kokomen/types"; import { motion } from "motion/react"; import { Chart as ChartJS, @@ -18,11 +21,11 @@ import { Check } from "lucide-react"; ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend); export default function ResumeEvaluationResult({ - result + report }: { - result: CamelCasedProperties; + report: CamelCasedProperties; }) { - const categoryData = parseResumeEvaluationCategoryData(result); + const categoryData = parseResumeEvaluationCategoryData(report.result); return ( 총점:{" "} - {result.totalScore} + {report.result.totalScore}

@@ -157,7 +160,7 @@ export default function ResumeEvaluationResult({ 종합 피드백

- {result.totalFeedback} + {report.result.totalFeedback}

diff --git a/apps/client/src/domains/resume/context/resumeStore.tsx b/apps/client/src/domains/resume/context/resumeStore.tsx new file mode 100644 index 00000000..ab21437b --- /dev/null +++ b/apps/client/src/domains/resume/context/resumeStore.tsx @@ -0,0 +1,107 @@ +import { getResumeEvaluationState } from "@/domains/resume/api"; +import { useReportevent } from "@/domains/resume/utils/reportEventEmitter"; +import { RoundSpinner, Tooltip } from "@kokomen/ui"; +import { CheckIcon, X } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import Link from "next/link"; +import React, { createContext, useState } from "react"; + +type ResumeState = "IDLE" | "PENDING" | "COMPLETED" | "ERROR"; +interface IResumeStore { + reportState: ResumeState; + evaluationId: string | null; + // eslint-disable-next-line no-unused-vars + setEvaluationId: (evaluationId: string) => void; +} +const ResumeStore = createContext(null); + +export default function ResumeStoreProvider({ + children +}: { + children: React.ReactNode; +}) { + const [reportState, setReportState] = useState("IDLE"); + const [evaluationId, setEvaluationId] = useState(null); + + useReportevent("report:submitted", async (payload) => { + try { + setReportState("PENDING"); + setEvaluationId(payload.evaluation_id); + const response = await getResumeEvaluationState(payload.evaluation_id); + if (response.state === "COMPLETED") { + setReportState("COMPLETED"); + } + } catch (error) { + setReportState("ERROR"); + } + }); + return ( + + + {reportState === "PENDING" && ( + + + + 이력서 평가 중... + + + + + )} + {reportState === "COMPLETED" && ( + { + setReportState("IDLE"); + setEvaluationId(null); + }} + > + + + + 이력서 평가 완료 + + + + + + )} + {reportState === "ERROR" && ( + + { + setReportState("IDLE"); + setEvaluationId(null); + }} + > + + 이력서 평가 중
오류가 발생했어요 +
+ +
+
+ )} +
+ {children} +
+ ); +} diff --git a/apps/client/src/domains/resume/utils/reportEventEmitter.ts b/apps/client/src/domains/resume/utils/reportEventEmitter.ts new file mode 100644 index 00000000..6f25ff0c --- /dev/null +++ b/apps/client/src/domains/resume/utils/reportEventEmitter.ts @@ -0,0 +1,22 @@ +/* eslint-disable no-unused-vars */ +import { publishEvent, useSubscribeEvents } from "@/utils/eventEmitter"; +import { ReportEventPayloads, ReportEventType } from "@kokomen/types"; +import { DependencyList } from "react"; +// 이벤트에 대서 콜백 함수 구독하는 훅 +export function useReportevent( + event: K, + handler: ReportEventPayloads[K] extends undefined + ? () => void + : (payload: ReportEventPayloads[K]) => void, + deps: DependencyList = [] +): void { + const eventEmitter = useSubscribeEvents( + [{ event, handler }], + [] + ); +} + +export const publishReportEvent = publishEvent< + ReportEventType, + ReportEventPayloads +>(); diff --git a/apps/client/src/domains/resume/utils/resumeEvaluation.ts b/apps/client/src/domains/resume/utils/resumeEvaluation.ts index 8bbb9b4f..6d89a7a8 100644 --- a/apps/client/src/domains/resume/utils/resumeEvaluation.ts +++ b/apps/client/src/domains/resume/utils/resumeEvaluation.ts @@ -16,7 +16,9 @@ export const resumeEvaluation = (score: number) => { }; export const parseResumeEvaluationCategoryData = ( - resumeAnalysisResult: CamelCasedProperties + resumeAnalysisResult: CamelCasedProperties< + CamelCasedProperties + > ): { key: string; label: string; diff --git a/apps/client/src/pages/_app.tsx b/apps/client/src/pages/_app.tsx index 8da9504c..196c1434 100644 --- a/apps/client/src/pages/_app.tsx +++ b/apps/client/src/pages/_app.tsx @@ -6,6 +6,7 @@ import { Toaster } from "@kokomen/ui"; import { ErrorBoundary } from "@sentry/nextjs"; import ErrorFallback from "@/shared/errorFallback"; import FeedbackButton from "@/shared/feedbackButton"; +import ResumeStoreProvider from "@/domains/resume/context/resumeStore"; const queryClient: QueryClient = new QueryClient(); @@ -15,8 +16,10 @@ export default function App({ Component, pageProps }: AppProps): JSX.Element { }> - - + + + + diff --git a/apps/client/src/pages/interviews/[interviewId]/index.tsx b/apps/client/src/pages/interviews/[interviewId]/index.tsx index c07eb7e5..2dd152aa 100644 --- a/apps/client/src/pages/interviews/[interviewId]/index.tsx +++ b/apps/client/src/pages/interviews/[interviewId]/index.tsx @@ -3,7 +3,7 @@ import { InterviewAnswerForm } from "@/domains/interview/components/interviewAns import { InterviewSideBar } from "@kokomen/ui/domains"; import { useModal } from "@kokomen/utils"; import { - interviewEventHelpers, + publishInterviewEvent, useInterviewEvent } from "@/domains/interview/utils/interviewEventEmitter"; import React, { JSX, useState } from "react"; @@ -99,17 +99,17 @@ export default function InterviewPage({ onPlayEnd: () => { setIsSpeaking(false); if (mode === "VOICE") { - interviewEventHelpers.startVoiceRecognition(); + publishInterviewEvent("interview:startVoiceRecognition"); } }, onPlayStart: () => { setIsSpeaking(true); } }); - useInterviewEvent("voiceRecognitionStarted", () => { + useInterviewEvent("interview:voiceRecognitionStarted", () => { setIsListening(true); }); - useInterviewEvent("voiceRecognitionStopped", () => { + useInterviewEvent("interview:voiceRecognitionStopped", () => { setIsListening(false); }); diff --git a/apps/client/src/pages/resume/eval/[evaluationId]/result.tsx b/apps/client/src/pages/resume/eval/[evaluationId]/result.tsx new file mode 100644 index 00000000..8b95cb65 --- /dev/null +++ b/apps/client/src/pages/resume/eval/[evaluationId]/result.tsx @@ -0,0 +1,77 @@ +import { getUserInfo } from "@/domains/auth/api"; +import { getResumeEvaluationResult } from "@/domains/resume/api"; +import { ResumeEvaluationResult as ResumeEvaluationResultComponent } from "@/domains/resume/components"; +import Header from "@/shared/header"; +import { SEO } from "@/shared/seo"; +import { + CamelCasedProperties, + ResumeEvaluationResult, + UserInfo +} from "@kokomen/types"; +import { + GetServerSideProps, + GetServerSidePropsResult, + InferGetServerSidePropsType +} from "next"; + +export default function ResumeEvalResultPage({ + userInfo, + resumeEvaluationState +}: InferGetServerSidePropsType) { + return ( + <> + +
+
+
+ +
+
+ + ); +} + +export const getServerSideProps: GetServerSideProps<{ + userInfo: UserInfo | null; + resumeEvaluationState: CamelCasedProperties; +}> = async ( + context +): Promise< + GetServerSidePropsResult<{ + userInfo: UserInfo | null; + resumeEvaluationState: CamelCasedProperties; + }> +> => { + try { + const evaluationId = context.params?.evaluationId; + if (!evaluationId) { + return { notFound: true }; + } + const response = await Promise.allSettled([ + getUserInfo(context), + getResumeEvaluationResult(evaluationId as string, context) + ]); + + const userInfo = + response[0].status === "fulfilled" ? response[0].value.data : null; + const resumeEvaluationState = + response[1].status === "fulfilled" ? response[1].value : null; + if (!resumeEvaluationState) { + return { notFound: true }; + } + return { + props: { + userInfo, + resumeEvaluationState + } + }; + } catch (error) { + return { redirect: { destination: "/error", permanent: false } }; + } +}; diff --git a/apps/client/src/pages/resume/eval/index.tsx b/apps/client/src/pages/resume/eval/index.tsx index 6bb9b2d1..51eba786 100644 --- a/apps/client/src/pages/resume/eval/index.tsx +++ b/apps/client/src/pages/resume/eval/index.tsx @@ -6,11 +6,9 @@ import { InferGetServerSidePropsType } from "next"; import { Footer } from "@/shared/footer"; -import { CamelCasedProperties, ResumeOutput, UserInfo } from "@kokomen/types"; +import { UserInfo } from "@kokomen/types"; import { SEO } from "@/shared/seo"; import dynamic from "next/dynamic"; -import { useState } from "react"; -import { ResumeEvaluationResult } from "@/domains/resume/components"; import { AxiosError } from "axios"; const ResumeEvalForm = dynamic( @@ -23,9 +21,6 @@ const ResumeEvalForm = dynamic( export default function ResumeEvalPage({ userInfo }: InferGetServerSidePropsType) { - const [result, setResult] = - useState | null>(null); - return ( <>
- {result ? ( - - ) : ( - - )} +