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 = () => (
-
-
-
-
보고서를 생성하는 중이에요. 잠시만 기다려주세요.
-
최대 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({
@@ -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 ? (
-
- ) : (
-
- )}
+
diff --git a/apps/client/src/utils/eventEmitter.ts b/apps/client/src/utils/eventEmitter.ts
new file mode 100644
index 00000000..e278d915
--- /dev/null
+++ b/apps/client/src/utils/eventEmitter.ts
@@ -0,0 +1,107 @@
+import EventEmitter from "events";
+import { useEffect } from "react";
+
+type EventType = string;
+interface EventPayloads {
+ [key: EventType]: any;
+}
+class TypedEventEmitter extends EventEmitter {
+ public emit(
+ event: K,
+ ...args: EventPayloads[K] extends undefined ? [] : [EventPayloads[K]]
+ ): boolean {
+ return super.emit(event, ...args);
+ }
+
+ public on(
+ event: K,
+ listener: EventPayloads[K] extends undefined
+ ? () => void
+ : // eslint-disable-next-line no-unused-vars
+ (payload: EventPayloads[K]) => void
+ ): this {
+ return super.on(event, listener);
+ }
+
+ public off(
+ event: K,
+ listener: EventPayloads[K] extends undefined
+ ? () => void
+ : // eslint-disable-next-line no-unused-vars
+ (payload: EventPayloads[K]) => void
+ ): this {
+ return super.off(event, listener);
+ }
+
+ public once(
+ event: K,
+ listener: EventPayloads[K] extends undefined
+ ? () => void
+ : // eslint-disable-next-line no-unused-vars
+ (payload: EventPayloads[K]) => void
+ ): this {
+ return super.once(event, listener);
+ }
+}
+
+// 중앙화된 싱글톤 인스턴스
+export const GlobalEventBus = new TypedEventEmitter();
+
+// useEventEmitter.ts
+
+// 이벤트 핸들러의 타입을 명확하게 제네릭으로 정의
+type EventHandler = EventPayloads[K] extends undefined
+ ? () => void
+ : // eslint-disable-next-line no-unused-vars
+ (payload: EventPayloads[K]) => void;
+
+type EventEmitterContext = {
+ event: K;
+ handler: EventHandler;
+};
+
+// 훅의 인수를 보다 타입 안전하게 정의
+// T는 EventEmitterContext의 배열이 되도록 제한
+// 이벤트 구독의 경우 컴포넌트가 마운트되었을 시기에 구독한 후 언마운트 시 이에 대해 해제해야 하므로 훅으로 개발
+export function useSubscribeEvents(
+ events: EventEmitterContext[],
+ deps: any[] = []
+): void {
+ useEffect(() => {
+ events.forEach(({ event, handler }) => {
+ // 1. 싱글톤을 사용
+ // 2. on 메서드의 타입 추론 덕분에 handler 인수의 타입이 자동으로 유추됩니다.
+ GlobalEventBus.on(event, handler as EventHandler);
+ });
+ return () => {
+ events.forEach(({ event, handler }) => {
+ GlobalEventBus.off(event, handler as EventHandler);
+ });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [events, ...deps]);
+}
+
+// 제네릭을 받는 PublishEventFunctio
+type PublishEventFunction<
+ TEvent extends string, // TEvent는 문자열 리터럴 타입의 유니온이어야 함
+ TPayloads extends Record // TPayloads는 TEvent의 각 항목을 키로 가져야 함
+> = (
+ // 제네릭 K는 TEvent의 하위 타입
+
+ // eslint-disable-next-line no-unused-vars
+ event: K,
+ // TPayloads[K]를 사용하여 조건부 타입 적용
+ // eslint-disable-next-line no-unused-vars
+ ...args: TPayloads[K] extends undefined ? [] : [TPayloads[K]]
+) => boolean;
+
+export function publishEvent<
+ TEvent extends string,
+ TPayloads extends Record
+>(): PublishEventFunction {
+ return (
+ event: K,
+ ...args: TPayloads[K] extends undefined ? [] : [TPayloads[K]]
+ ) => GlobalEventBus.emit(event, ...args);
+}
diff --git a/apps/client/src/utils/querykeys.ts b/apps/client/src/utils/querykeys.ts
index b9e0843c..dc0151d8 100644
--- a/apps/client/src/utils/querykeys.ts
+++ b/apps/client/src/utils/querykeys.ts
@@ -100,14 +100,25 @@ const recruitKeys: QueryKeyFactory = {
] as const
};
+type ArchiveMethods = {
+ resumes: (type?: "ALL" | "RESUME" | "PORTFOLIO") => QueryKey;
+};
+const archiveKeys: QueryKeyFactory = {
+ all: ["archive"] as const,
+ resumes: (type?: "ALL" | "RESUME" | "PORTFOLIO"): QueryKey =>
+ [...archiveKeys.all, "resumes", type ?? "ALL"] as const
+};
+
export {
interviewHistoryKeys,
interviewKeys,
memberKeys,
+ archiveKeys,
purchaseKeys,
recruitKeys,
type InterviewHistoryParams,
type InterviewParams,
type MemberRankParams,
- type RecruitMethods
+ type RecruitMethods,
+ type ArchiveMethods
};
diff --git a/apps/kokomen-webview/package.json b/apps/kokomen-webview/package.json
index 6b1e7fa3..d7baf9b8 100644
--- a/apps/kokomen-webview/package.json
+++ b/apps/kokomen-webview/package.json
@@ -14,6 +14,7 @@
"@apollo/client": "^4.0.6",
"@hookform/resolvers": "^5.1.1",
"@kokomen/ui": "workspace:*",
+ "@kokomen/utils": "workspace:*",
"@tanstack/react-query": "^5.80.6",
"@tanstack/react-router": "^1.129.3",
"@tanstack/react-router-devtools": "^1.129.3",
@@ -37,7 +38,6 @@
"@eslint/js": "^9.31.0",
"@kokomen/eslint-config": "workspace:*",
"@kokomen/types": "workspace:*",
- "@kokomen/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/router-plugin": "^1.129.3",
"@testing-library/dom": "^10.4.0",
diff --git a/apps/kokomen-webview/src/domains/interviews/components/interviewAnswerForm.tsx b/apps/kokomen-webview/src/domains/interviews/components/interviewAnswerForm.tsx
index 279c5075..554f9d4c 100644
--- a/apps/kokomen-webview/src/domains/interviews/components/interviewAnswerForm.tsx
+++ b/apps/kokomen-webview/src/domains/interviews/components/interviewAnswerForm.tsx
@@ -23,7 +23,7 @@ import {
} from "@/domains/interviews/api/interviewAnswer";
import useSpeechRecognition from "@/domains/interviews/hooks/useSpeechRecognition";
import { captureFormSubmitEvent } from "@/utils/analytics";
-import interviewEventHelpers from "@/domains/interviews/lib/interviewEventHelpers";
+import { publishInterviewEvent } from "@/domains/interviews/lib/interviewEventHelpers";
type InterviewInputProps = Pick<
CamelCasedProperties,
@@ -105,7 +105,7 @@ export function InterviewAnswerForm({
}
});
if (mode === "VOICE") {
- interviewEventHelpers.stopVoiceRecognition();
+ publishInterviewEvent("interview:stopVoiceRecognition");
}
const previousMessage = {
prevMessage: curQuestion,
diff --git a/apps/kokomen-webview/src/domains/interviews/lib/interviewEventHelpers.ts b/apps/kokomen-webview/src/domains/interviews/lib/interviewEventHelpers.ts
index 9c87bbd7..a14b45aa 100644
--- a/apps/kokomen-webview/src/domains/interviews/lib/interviewEventHelpers.ts
+++ b/apps/kokomen-webview/src/domains/interviews/lib/interviewEventHelpers.ts
@@ -1,14 +1,20 @@
-const interviewEventHelpers = {
- startVoiceRecognition: () => {
- window.ReactNativeWebView?.postMessage(
- JSON.stringify({ type: "startListening" })
- );
- },
- stopVoiceRecognition: () => {
- window.ReactNativeWebView?.postMessage(
- JSON.stringify({ type: "stopListening" })
- );
- }
-};
+import { publishEvent, useSubscribeEvents } from "@/utils/events";
+import { InterviewEventType, InterviewEventPayloads } from "@kokomen/types";
+import { DependencyList } from "react";
-export default interviewEventHelpers;
+export function useInterviewEvent(
+ event: K,
+ handler: InterviewEventPayloads[K] extends undefined
+ ? () => void
+ : // eslint-disable-next-line no-unused-vars
+ (payload: InterviewEventPayloads[K]) => void,
+ // eslint-disable-next-line no-unused-vars
+ deps: DependencyList = []
+): void {
+ useSubscribeEvents([{ event, handler }], []);
+}
+
+export const publishInterviewEvent = publishEvent<
+ InterviewEventType,
+ InterviewEventPayloads
+>();
diff --git a/apps/kokomen-webview/src/routes/interviews/$interviewId/index.tsx b/apps/kokomen-webview/src/routes/interviews/$interviewId/index.tsx
index 1bff8071..218b064d 100644
--- a/apps/kokomen-webview/src/routes/interviews/$interviewId/index.tsx
+++ b/apps/kokomen-webview/src/routes/interviews/$interviewId/index.tsx
@@ -19,7 +19,7 @@ import { InterviewAnswerForm } from "@/domains/interviews/components/interviewAn
import { InterviewQuestion, InterviewSideBar } from "@kokomen/ui/domains";
import InterviewFinishModal from "@/domains/interviews/components/interviewFinishModal";
import InterviewStartModal from "@/domains/interviews/components/interviewStartModal";
-import interviewEventHelpers from "@/domains/interviews/lib/interviewEventHelpers";
+import { publishInterviewEvent } from "@/domains/interviews/lib/interviewEventHelpers";
// eslint-disable-next-line @rushstack/typedef-var
export const Route = createFileRoute("/interviews/$interviewId/")({
@@ -118,13 +118,13 @@ function RouteComponent(): ReactNode {
onPlayEnd: () => {
setIsSpeaking(false);
if (mode === "VOICE") {
- interviewEventHelpers.startVoiceRecognition();
+ publishInterviewEvent("interview:startVoiceRecognition");
}
},
onPlayStart: () => {
setIsSpeaking(true);
if (mode === "VOICE") {
- interviewEventHelpers.stopVoiceRecognition();
+ publishInterviewEvent("interview:stopVoiceRecognition");
}
}
});
diff --git a/apps/kokomen-webview/src/utils/events.ts b/apps/kokomen-webview/src/utils/events.ts
new file mode 100644
index 00000000..e278d915
--- /dev/null
+++ b/apps/kokomen-webview/src/utils/events.ts
@@ -0,0 +1,107 @@
+import EventEmitter from "events";
+import { useEffect } from "react";
+
+type EventType = string;
+interface EventPayloads {
+ [key: EventType]: any;
+}
+class TypedEventEmitter extends EventEmitter {
+ public emit(
+ event: K,
+ ...args: EventPayloads[K] extends undefined ? [] : [EventPayloads[K]]
+ ): boolean {
+ return super.emit(event, ...args);
+ }
+
+ public on(
+ event: K,
+ listener: EventPayloads[K] extends undefined
+ ? () => void
+ : // eslint-disable-next-line no-unused-vars
+ (payload: EventPayloads[K]) => void
+ ): this {
+ return super.on(event, listener);
+ }
+
+ public off(
+ event: K,
+ listener: EventPayloads[K] extends undefined
+ ? () => void
+ : // eslint-disable-next-line no-unused-vars
+ (payload: EventPayloads[K]) => void
+ ): this {
+ return super.off(event, listener);
+ }
+
+ public once(
+ event: K,
+ listener: EventPayloads[K] extends undefined
+ ? () => void
+ : // eslint-disable-next-line no-unused-vars
+ (payload: EventPayloads[K]) => void
+ ): this {
+ return super.once(event, listener);
+ }
+}
+
+// 중앙화된 싱글톤 인스턴스
+export const GlobalEventBus = new TypedEventEmitter();
+
+// useEventEmitter.ts
+
+// 이벤트 핸들러의 타입을 명확하게 제네릭으로 정의
+type EventHandler = EventPayloads[K] extends undefined
+ ? () => void
+ : // eslint-disable-next-line no-unused-vars
+ (payload: EventPayloads[K]) => void;
+
+type EventEmitterContext = {
+ event: K;
+ handler: EventHandler;
+};
+
+// 훅의 인수를 보다 타입 안전하게 정의
+// T는 EventEmitterContext의 배열이 되도록 제한
+// 이벤트 구독의 경우 컴포넌트가 마운트되었을 시기에 구독한 후 언마운트 시 이에 대해 해제해야 하므로 훅으로 개발
+export function useSubscribeEvents(
+ events: EventEmitterContext[],
+ deps: any[] = []
+): void {
+ useEffect(() => {
+ events.forEach(({ event, handler }) => {
+ // 1. 싱글톤을 사용
+ // 2. on 메서드의 타입 추론 덕분에 handler 인수의 타입이 자동으로 유추됩니다.
+ GlobalEventBus.on(event, handler as EventHandler);
+ });
+ return () => {
+ events.forEach(({ event, handler }) => {
+ GlobalEventBus.off(event, handler as EventHandler);
+ });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [events, ...deps]);
+}
+
+// 제네릭을 받는 PublishEventFunctio
+type PublishEventFunction<
+ TEvent extends string, // TEvent는 문자열 리터럴 타입의 유니온이어야 함
+ TPayloads extends Record // TPayloads는 TEvent의 각 항목을 키로 가져야 함
+> = (
+ // 제네릭 K는 TEvent의 하위 타입
+
+ // eslint-disable-next-line no-unused-vars
+ event: K,
+ // TPayloads[K]를 사용하여 조건부 타입 적용
+ // eslint-disable-next-line no-unused-vars
+ ...args: TPayloads[K] extends undefined ? [] : [TPayloads[K]]
+) => boolean;
+
+export function publishEvent<
+ TEvent extends string,
+ TPayloads extends Record
+>(): PublishEventFunction {
+ return (
+ event: K,
+ ...args: TPayloads[K] extends undefined ? [] : [TPayloads[K]]
+ ) => GlobalEventBus.emit(event, ...args);
+}
diff --git a/nginx/nginx.dev.conf b/nginx/nginx.dev.conf
index 67ebc6bd..b3f7d140 100644
--- a/nginx/nginx.dev.conf
+++ b/nginx/nginx.dev.conf
@@ -25,11 +25,6 @@ http {
keepalive 32;
}
- upstream kokomen-nest-server-dev {
- server kokomen-nest-server-dev:3001;
- keepalive 32;
- }
-
# 공통 로그 포맷
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
@@ -114,6 +109,8 @@ http {
listen [::]:443 ssl http2;
server_name api-dev.kokomen.kr;
+ client_max_body_size 25M;
+
ssl_certificate /etc/letsencrypt/live/api-dev.kokomen.kr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api-dev.kokomen.kr/privkey.pem;
@@ -126,7 +123,7 @@ http {
add_header Referrer-Policy strict-origin-when-cross-origin always;
location /api/v3 {
- proxy_pass http://kokomen-nest-server-dev;
+ proxy_pass http://api_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
diff --git a/packages/kokomen-utils/src/general/formdata.ts b/packages/kokomen-utils/src/general/formdata.ts
new file mode 100644
index 00000000..bfbb25bf
--- /dev/null
+++ b/packages/kokomen-utils/src/general/formdata.ts
@@ -0,0 +1,17 @@
+function generateFormData(data: Record): FormData {
+ const formData = new FormData();
+ for (const key in data) {
+ if (Object.prototype.hasOwnProperty.call(data, key) && data[key]) {
+ if (data[key] instanceof FileList) {
+ for (const file of Array.from(data[key])) {
+ formData.append(key, file);
+ }
+ } else {
+ formData.append(key, data[key]);
+ }
+ }
+ }
+ return formData;
+}
+
+export { generateFormData };
diff --git a/packages/kokomen-utils/src/general/index.ts b/packages/kokomen-utils/src/general/index.ts
index 5b32931d..455a72b9 100644
--- a/packages/kokomen-utils/src/general/index.ts
+++ b/packages/kokomen-utils/src/general/index.ts
@@ -5,3 +5,4 @@ export * from "./querykeys";
export * from "./emotion";
export * from "./delay";
export { default as ParamSerializer } from "./paramSerializer";
+export * from "./formdata";
diff --git a/packages/kokomen-utils/src/index.ts b/packages/kokomen-utils/src/index.ts
index 599429cb..94d1cf2a 100644
--- a/packages/kokomen-utils/src/index.ts
+++ b/packages/kokomen-utils/src/index.ts
@@ -9,6 +9,7 @@ export type {
export {
formatDate,
getVisiblePageNumbers,
+ generateFormData,
interviewHistoryKeys,
interviewKeys,
memberKeys,
diff --git a/packages/types/src/events/index.ts b/packages/types/src/events/index.ts
new file mode 100644
index 00000000..11786bb4
--- /dev/null
+++ b/packages/types/src/events/index.ts
@@ -0,0 +1,2 @@
+export * from "./interview";
+export * from "./report";
diff --git a/packages/types/src/events/interview.ts b/packages/types/src/events/interview.ts
new file mode 100644
index 00000000..66e5959c
--- /dev/null
+++ b/packages/types/src/events/interview.ts
@@ -0,0 +1,12 @@
+interface InterviewEventPayloads {
+ "interview:startVoiceRecognition": undefined;
+ "interview:stopVoiceRecognition": undefined;
+ "interview:voiceRecognitionStarted": undefined;
+ "interview:voiceRecognitionStopped": undefined;
+ "interview:voiceRecognitionError": { error: string };
+ "interview:voiceRecognitionResult": { text: string };
+}
+
+type InterviewEventType = keyof InterviewEventPayloads;
+
+export type { InterviewEventType, InterviewEventPayloads };
diff --git a/packages/types/src/events/report.ts b/packages/types/src/events/report.ts
new file mode 100644
index 00000000..93f33576
--- /dev/null
+++ b/packages/types/src/events/report.ts
@@ -0,0 +1,9 @@
+interface ReportEventPayloads {
+ "report:submitted": { evaluation_id: string };
+ "report:created": undefined;
+ "report:updated": undefined;
+ "report:error": { error: string };
+}
+
+type ReportEventType = keyof ReportEventPayloads;
+export type { ReportEventType, ReportEventPayloads };
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index b69e15e7..2ccc1516 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -2,6 +2,7 @@ export * from "./auth";
export * from "./interviews";
export * from "./reports";
export * from "./members";
+export * from "./events";
export * from "./dashboard";
export * from "./utils";
export * from "./notifications";
diff --git a/packages/types/src/resume/index.ts b/packages/types/src/resume/index.ts
index 6c5a3c23..8d565c1c 100644
--- a/packages/types/src/resume/index.ts
+++ b/packages/types/src/resume/index.ts
@@ -1,39 +1,89 @@
-type ResumeInput = {
- resume: string;
- portfolio?: string;
+type ArchivedResumeAndPortfolio = {
+ id: number;
+ title: string;
+ url: string;
+ created_at: string;
+};
+
+type ResumeInput = ResumeInputWithNewFile | ResumeInputWithArchivedFile;
+
+type ResumeInputWithNewFile = {
+ resume: File;
+ portfolio?: File;
job_position: string;
job_description?: string;
job_career: string;
};
+type ResumeInputWithArchivedFile = {
+ resume_id: string;
+ portfolio_id?: string;
+ job_position: string;
+ job_description?: string;
+ job_career: string;
+};
+
+type ResumePending = {
+ state: "PENDING";
+};
+type ResumeFailed = {
+ state: "FAILED";
+};
type ResumeOutput = {
- technical_skills: {
- score: number;
- reason: string;
- improvements: string;
- };
- project_experience: {
- score: number;
- reason: string;
- improvements: string;
- };
- problem_solving: {
- score: number;
- reason: string;
- improvements: string;
+ state: "COMPLETED";
+ result: {
+ technical_skills: {
+ score: number;
+ reason: string;
+ improvements: string;
+ };
+ project_experience: {
+ score: number;
+ reason: string;
+ improvements: string;
+ };
+ problem_solving: {
+ score: number;
+ reason: string;
+ improvements: string;
+ };
+ career_growth: {
+ score: number;
+ reason: string;
+ improvements: string;
+ };
+ documentation: {
+ score: number;
+ reason: string;
+ improvements: string;
+ };
+ total_score: number;
+ total_feedback: string;
};
- career_growth: {
- score: number;
- reason: string;
- improvements: string;
+};
+
+type ResumeEvaluationResult = {
+ id: number;
+ resume: {
+ id: number;
+ title: string;
};
- documentation: {
- score: number;
- reason: string;
- improvements: string;
+ portfolio: {
+ id: number;
+ title: string;
};
- total_score: number;
- total_feedback: string;
+ job_position: string;
+ job_description: string;
+ job_career: string;
+ result: ResumeOutput["result"];
+};
+export type {
+ ResumeInput,
+ ResumeOutput,
+ ArchivedResumeAndPortfolio,
+ ResumePending,
+ ResumeInputWithArchivedFile,
+ ResumeInputWithNewFile,
+ ResumeFailed,
+ ResumeEvaluationResult
};
-
-export type { ResumeInput, ResumeOutput };
diff --git a/packages/ui/src/components/form/fields.tsx b/packages/ui/src/components/form/fields.tsx
index f4a68199..e7556dc5 100644
--- a/packages/ui/src/components/form/fields.tsx
+++ b/packages/ui/src/components/form/fields.tsx
@@ -4,13 +4,15 @@ import { useRef, useState } from "react";
import { UseFormRegisterReturn } from "react-hook-form";
import { cn } from "../../utils/index.ts";
+const MAX_FILE_SIZE = 5 * 1024 * 1024; // 10MB
const FileField = ({
register,
label,
required,
error,
hint,
- disabled
+ disabled,
+ displayName
}: {
register: UseFormRegisterReturn;
label: string;
@@ -18,11 +20,21 @@ const FileField = ({
error?: string;
hint?: string;
disabled?: boolean;
+ displayName?: string;
}) => {
const [fileName, setFileName] = useState("");
const inputRef = useRef(null);
+ const [internalError, setInternalError] = useState("");
const handleFileChange = async (e: React.ChangeEvent) => {
+ setInternalError("");
+ if (
+ e.target.files?.[0] instanceof File &&
+ e.target.files?.[0]?.size > MAX_FILE_SIZE
+ ) {
+ setInternalError("파일 크기가 너무 큽니다. 10MB 이하로 업로드해주세요.");
+ return;
+ }
setFileName(e.target.files?.[0]?.name || "");
await register.onChange(e);
};
@@ -33,7 +45,7 @@ const FileField = ({
};
return (
-
+
);
};
diff --git a/packages/ui/src/components/toast/index.tsx b/packages/ui/src/components/toast/index.tsx
index e8747d6b..88d75a73 100644
--- a/packages/ui/src/components/toast/index.tsx
+++ b/packages/ui/src/components/toast/index.tsx
@@ -94,7 +94,7 @@ const toastVariants = cva(
"border-error-border bg-error-bg text-error-text shadow-box-shadow",
warning:
"border-warning-border bg-warning-bg text-warning-text shadow-box-shadow",
- info: "border-info-border bg-info-bg text-info-text shadow-box-shadow"
+ info: "border-info-border text-info-text shadow-box-shadow bg-white"
}
},
defaultVariants: {