Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/features/feedback-page/api/getInterviewStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { apiInstance } from "@/shared/api/axiosInstance";

const GET_INTERVIEW_URL = "/api/interviews/get";

const readStatus = (value: unknown) => {
if (!value || typeof value !== "object") {
return null;
}

const { status } = value as { status?: unknown };
return typeof status === "string" ? status : null;
};

export const getInterviewStatus = async (
interviewId: number,
{ signal, timeout }: { signal?: AbortSignal; timeout?: number } = {},
) => {
if (!Number.isSafeInteger(interviewId) || interviewId <= 0) {
throw new Error("조회할 면접 정보가 없습니다.");
}

const response = await apiInstance.get<unknown>(GET_INTERVIEW_URL, {
params: { interviewId },
signal,
timeout,
});
const payload = response.data as { data?: unknown } | null;

return readStatus(payload?.data) ?? readStatus(payload);
};
98 changes: 96 additions & 2 deletions src/features/feedback-page/model/useFeedbackInterview.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import axios from "axios";
import { useCallback, useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";

import {
getInterviewAnswers,
type InterviewAnswer,
} from "@/features/feedback-page/api/getInterviewAnswers";
import { getInterviewStatus } from "@/features/feedback-page/api/getInterviewStatus";

import {
getAllFeedbacks,
Expand All @@ -21,7 +23,69 @@ import {
} from "@/features/feedback-page/model/interviewDisplay";
import { extractErrorMessage } from "@/shared/api/errorMessage";

const INTERVIEW_SAVE_CHECK_INTERVAL_MS = 3_000;
const INTERVIEW_SAVE_CHECK_TIMEOUT_MS = 60_000;
const INTERVIEW_SAVE_REQUEST_TIMEOUT_MS = 15_000;

const isFromCompletion = (state: unknown) =>
!!state && typeof state === "object" &&
(state as { fromCompletion?: unknown }).fromCompletion === true;

const isRetryableError = (error: unknown) => {
if (!axios.isAxiosError(error) || axios.isCancel(error)) return false;
const status = error.response?.status;
return !error.response || status === 408 || status === 429 ||
(status !== undefined && status >= 500 && status < 600);
};

const waitFor = (ms: number, signal: AbortSignal) => new Promise<void>((resolve) => {
const handleAbort = () => {
window.clearTimeout(timeoutId);
resolve();
};
const timeoutId = window.setTimeout(() => {
signal.removeEventListener("abort", handleAbort);
resolve();
}, ms);
signal.addEventListener("abort", handleAbort, { once: true });
});

// 완료 직후에는 Chat → API 저장이 끝나기 전이라 IN_PROGRESS일 수 있어 정해진 시간만 다시 조회한다.
const waitForInterviewCompletion = async (interviewId: number, signal: AbortSignal) => {
const deadline = Date.now() + INTERVIEW_SAVE_CHECK_TIMEOUT_MS;
let status = "IN_PROGRESS";
let error: unknown = null;

while (!signal.aborted) {
const remainingTime = deadline - Date.now();
if (remainingTime <= 0) break;

await waitFor(Math.min(INTERVIEW_SAVE_CHECK_INTERVAL_MS, remainingTime), signal);
if (signal.aborted) break;

try {
// 응답이 멈춰도 60초 기한을 크게 넘기지 않도록 요청마다 시간 제한을 둔다.
const requestTimeout = Math.min(
INTERVIEW_SAVE_REQUEST_TIMEOUT_MS,
Math.max(deadline - Date.now(), 1_000),
);
status = (await getInterviewStatus(interviewId, { signal, timeout: requestTimeout })) ?? status;
if (status !== "IN_PROGRESS") break;
} catch (requestError) {
if (signal.aborted) break;
if (!isRetryableError(requestError)) {
error = requestError;
break;
}
}
}

return { status, error };
};

export const useFeedbackInterview = (id?: string, includePreviousFeedback = false) => {
const location = useLocation();
const fromCompletion = isFromCompletion(location.state);
const [answers, setAnswers] = useState<InterviewAnswer[]>([]);
const [answersErrorMessage, setAnswersErrorMessage] = useState<string | null>(null);
const [interview, setInterview] = useState<InterviewSummary | null>(null);
Expand All @@ -32,15 +96,20 @@ export const useFeedbackInterview = (id?: string, includePreviousFeedback = fals
);
const [isLoading, setIsLoading] = useState(true);
const [isRetryingFeedback, setIsRetryingFeedback] = useState(false);
const [isSavingInterview, setIsSavingInterview] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const requestIdRef = useRef(0);
const isRetryingFeedbackRef = useRef(false);
const saveCheckControllerRef = useRef<AbortController | null>(null);

const loadInterview = useCallback(async () => {
const requestId = ++requestIdRef.current;
const interviewId = Number(id);
saveCheckControllerRef.current?.abort();
saveCheckControllerRef.current = null;
isRetryingFeedbackRef.current = false;
setIsRetryingFeedback(false);
setIsSavingInterview(false);
setAnswers([]);
setAnswersErrorMessage(null);
setFeedback(null);
Expand Down Expand Up @@ -78,11 +147,29 @@ export const useFeedbackInterview = (id?: string, includePreviousFeedback = fals

if (interviewsResult.status === "fulfilled") {
const sortedInterviews = sortInterviewsByCreatedAt(interviewsResult.value);
const nextInterview =
let nextInterview =
sortedInterviews.find((item) => getInterviewId(item) === interviewId) ??
null;

setInterview(nextInterview);
let saveCheckError: unknown = null;

if (fromCompletion && nextInterview?.status === "IN_PROGRESS") {
const controller = new AbortController();
saveCheckControllerRef.current = controller;
setIsSavingInterview(true);
const result = await waitForInterviewCompletion(interviewId, controller.signal);

if (requestId !== requestIdRef.current) {
return;
}

saveCheckControllerRef.current = null;
setIsSavingInterview(false);
nextInterview = { ...nextInterview, status: result.status };
saveCheckError = result.error;
setInterview(nextInterview);
}

if (nextInterview?.createdAt && historyResult.status === "fulfilled") {
const currentTime = new Date(nextInterview.createdAt).getTime();
Expand All @@ -106,6 +193,10 @@ export const useFeedbackInterview = (id?: string, includePreviousFeedback = fals

if (!nextInterview) {
setErrorMessage("면접 정보를 찾을 수 없습니다.");
} else if (saveCheckError) {
setFeedbackErrorMessage(
extractErrorMessage(saveCheckError, "면접 상태를 확인하지 못했습니다."),
);
} else if (nextInterview.status !== "COMPLETED") {
setFeedbackErrorMessage("아직 완료되지 않은 면접입니다. 면접을 마친 뒤 피드백을 확인해주세요.");
} else {
Expand Down Expand Up @@ -138,7 +229,7 @@ export const useFeedbackInterview = (id?: string, includePreviousFeedback = fals
}

setIsLoading(false);
}, [id, includePreviousFeedback]);
}, [fromCompletion, id, includePreviousFeedback]);

const retryFeedback = useCallback(async () => {
if (feedback?.status !== "FAILED") {
Expand Down Expand Up @@ -200,6 +291,8 @@ export const useFeedbackInterview = (id?: string, includePreviousFeedback = fals
return () => {
window.clearTimeout(timeoutId);
requestIdRef.current += 1;
saveCheckControllerRef.current?.abort();
saveCheckControllerRef.current = null;
};
}, [loadInterview]);

Expand Down Expand Up @@ -281,6 +374,7 @@ export const useFeedbackInterview = (id?: string, includePreviousFeedback = fals
previousFeedback,
isLoading,
isRetryingFeedback,
isSavingInterview,
refetch: loadInterview,
retryFeedback,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,9 @@ export const useInterviewSession = (
if (shouldNavigate && closeReasonRef.current === reason && !hasNavigatedRef.current) {
hasNavigatedRef.current = true;
navigate(getInterviewExitPath(reason), {
state: reason === "completed" ? { answeredQuestionCount: displayQuestionNumberRef.current } : undefined,
state: reason === "completed"
? { answeredQuestionCount: displayQuestionNumberRef.current, interviewId: preparedInterview?.interviewId ?? null }
: undefined,
});
}
return true;
Expand Down
5 changes: 4 additions & 1 deletion src/pages/feedback-page/feedback-overall/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const FeedbackOverallPage = () => {
previousFeedback,
isLoading,
isRetryingFeedback,
isSavingInterview,
refetch,
retryFeedback,
} = useFeedbackInterview(id, true);
Expand Down Expand Up @@ -57,7 +58,9 @@ const FeedbackOverallPage = () => {
<S.Panel ref={panelRef}>
{isLoading ? (
<S.StateCard aria-live="polite">
<S.StateText>면접 정보를 불러오는 중입니다.</S.StateText>
<S.StateText>
{isSavingInterview ? "면접 기록을 저장하고 있어요" : "면접 정보를 불러오는 중입니다."}
</S.StateText>
</S.StateCard>
) : errorMessage || !interview ? (
<S.StateCard aria-live="polite">
Expand Down
27 changes: 26 additions & 1 deletion src/widgets/interview-page/completed/completed-content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,35 @@ const getAnsweredQuestionCount = (state: unknown) => {
: null;
};

const getCompletedInterviewId = (state: unknown) => {
if (!state || typeof state !== "object") {
return null;
}

const { interviewId } = state as { interviewId?: unknown };
return typeof interviewId === "number" &&
Number.isSafeInteger(interviewId) &&
interviewId > 0
? interviewId
: null;
};

const InterviewCompletedContent = () => {
const navigate = useNavigate();
const location = useLocation();
const answeredQuestionCount = getAnsweredQuestionCount(location.state);
const interviewId = getCompletedInterviewId(location.state);

const handleOpenFeedback = () => {
if (interviewId === null) {
navigate("/main/feedback/list");
return;
}

navigate(`/main/feedback/overall/${interviewId}`, {
state: { fromCompletion: true },
});
};

return (
<S.Card>
Expand All @@ -36,7 +61,7 @@ const InterviewCompletedContent = () => {
</S.SecondaryButton>
<S.PrimaryButton
type="button"
onClick={() => navigate("/main/feedback/list")}
onClick={handleOpenFeedback}
>
피드백 보러가기
</S.PrimaryButton>
Expand Down
12 changes: 5 additions & 7 deletions src/widgets/interview-page/interview/interview-content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import QuestionAudioControl, { QuestionAudioError } from "../question-audio-cont
const TEXT_ANSWER_MAX_LENGTH = 10000;
const TEXT_ANSWER_PLACEHOLDER =
"이곳에 답변을 입력해주세요. 자신의 경험과 성과를 구체적인 수치와 함께 작성하면 좋은 평가를 받을 수 있습니다.";
const FALLBACK_TOTAL_QUESTION_COUNT = 10;

const formatQuestionLabel = ({
currentQuestionNumber,
Expand All @@ -29,10 +28,10 @@ const formatQuestionLabel = ({
? questionNumber
: `${questionNumber}-${followUpQuestionNumber}`;

return `Question ${questionLabel} / ${String(totalQuestionCount).padStart(
2,
"0",
)}`;
const totalLabel =
totalQuestionCount > 0 ? String(totalQuestionCount).padStart(2, "0") : "--";

return `Question ${questionLabel} / ${totalLabel}`;
};

const InterviewContentView = () => {
Expand All @@ -49,14 +48,13 @@ const InterviewContentView = () => {
onAnswerTextChange,
onSubmitText,
preparedInterview,
totalQuestionCount,
voiceLevel,
} = useInterviewSessionContext();
const isVoiceMode = mode === "voice";
const isTextMode = mode === "text";
const visualizerLevel = isVoiceStarted ? voiceLevel : 0.22;
const isQuestionLoading = currentQuestion === null;
const totalQuestionCount =
preparedInterview?.questions.length || FALLBACK_TOTAL_QUESTION_COUNT;
const currentQuestionNumber = Math.max(displayQuestionNumber, 1);
const activeInterviewer = preparedInterview?.interviewers?.find(
(interviewer) => interviewer.personaId === currentQuestion?.personaId,
Expand Down
4 changes: 2 additions & 2 deletions tests/interview-browser-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const browser = await chromium.launch({ headless: true, args: [
] });
export const question = (id) => ({ questionId: id, parentId: 0, type: "ORIGINAL", intention: "경험", content: `질문 ${id}: 프로젝트 경험을 설명해주세요.`, personaId: 1 });

export async function fixture({ multi = false, answerMode = "voice", timeout = false, denied = false, fail = false, delayUploads = false, complete = false, mimeMode, socketOnAnswer = false, preparationFailed = false, recorderFails = false, slowStop = false, audioResponses = null, holdAudio = false, fastAudioTimers = false, audioDeadline = false, playback = "native", recoveredSessionId = null, quitStatuses = [200] } = {}) {
export async function fixture({ multi = false, answerMode = "voice", timeout = false, denied = false, fail = false, delayUploads = false, complete = false, mimeMode, socketOnAnswer = false, preparationFailed = false, recorderFails = false, slowStop = false, audioResponses = null, holdAudio = false, fastAudioTimers = false, audioDeadline = false, playback = "native", recoveredSessionId = null, quitStatuses = [200], preparationQuestions = null } = {}) {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
page.setDefaultTimeout(10_000);
Expand Down Expand Up @@ -172,7 +172,7 @@ export async function fixture({ multi = false, answerMode = "voice", timeout = f
if (recoveredSessionId && pathname === "/api/questions/tailor" && ++recovery.reads <= 3) {
return json({ data: { status: "NOT_REQUESTED", preparationStatus: "NOT_REQUESTED", questions: [], interviewId: 42 } });
}
if (pathname === "/api/questions/tailor") return json({ data: { status: preparationFailed ? "FAILED" : "SUCCEEDED", preparationStatus: preparationFailed ? "FAILED" : "READY", errorMessage: preparationFailed ? "테스트 면접 준비 실패" : null, retryable: false, chatDelivered: !preparationFailed, questions: [current, question(102)], interviewId: 42 } });
if (pathname === "/api/questions/tailor") return json({ data: { status: preparationFailed ? "FAILED" : "SUCCEEDED", preparationStatus: preparationFailed ? "FAILED" : "READY", errorMessage: preparationFailed ? "테스트 면접 준비 실패" : null, retryable: false, chatDelivered: !preparationFailed, questions: preparationQuestions ?? [current, question(102)], interviewId: 42 } });
if (pathname.endsWith("/me")) return json({ data: { id: 7, name: "테스트", nickname: "테스트" } });
if (pathname.endsWith("/audio")) {
const id = Number(pathname.split("/").at(-2));
Expand Down
Loading