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
2 changes: 1 addition & 1 deletion src/hooks/useGetCommunityDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getCommunityDetail } from '@/apis/community';

import { useCoreQuery } from '@/hooks/customQuery';

export default function usegetCommunityDetail(param: TCommumityDetailRequest) {
export default function useGetCommunityDetail(param: TCommumityDetailRequest) {
const { data } = useCoreQuery(['getCommunityDetail', param.pageId], () => getCommunityDetail(param));
return { data };
}
6 changes: 3 additions & 3 deletions src/pages/Interview/feedback_result.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import InterviewLayout from '@/layouts/InterviewLayout';
import type { IFeedbackItem, IFinalFeedbackResponse } from '@/services/interviewApi';
import type { IFinalFeedbackResponse } from '@/services/interviewApi';
import { getFinalFeedback } from '@/services/interviewApi';

interface IQuestionState {
Expand Down Expand Up @@ -32,7 +32,7 @@ export default function FeedbackResult() {
setIsLoading(true);
const response = await getFinalFeedback(sessionId);
setFeedbackData(response);
const states = response.feedbacks.map((_, idx) => ({ id: idx + 1, showAnswer: false }));
const states = response.feedbacks.map((_, idx: number) => ({ id: idx + 1, showAnswer: false }));
setQuestionStates(states);
} catch (err) {
console.error('❌ 피드백 조회 실패:', err);
Expand Down Expand Up @@ -97,7 +97,7 @@ export default function FeedbackResult() {
</div>

<div className="grid grid-cols-2 gap-6 pb-8">
{feedbacks.map((item: IFeedbackItem, index: number) => {
{feedbacks.map((item, index: number) => {
const isShowingAnswer = questionStates.find((q) => q.id === index + 1)?.showAnswer || false;
const isPositive = item.feedbackType === 'positive';
const feedbackTypeLabel = isPositive ? 'AI 피드백(긍정)' : 'AI 피드백(개선)';
Expand Down
4 changes: 2 additions & 2 deletions src/pages/communityDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useNavigate, useParams } from 'react-router-dom';

import { formatUpdatedAt } from '@/utils/time';

import usegetCommunityDetail from '@/hooks/useGetCommunityDetail';
import useGetCommunityDetail from '@/hooks/useGetCommunityDetail';

import type { TValues } from '@/components/community/writeForm';
import WriteForm from '@/components/community/writeForm';
Expand All @@ -12,7 +12,7 @@ import { route } from '@/routes/route';
export default function CommunityDetail() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { data } = usegetCommunityDetail({ pageId: Number(id) });
const { data } = useGetCommunityDetail({ pageId: Number(id) });
const values: Partial<TValues> = data ?? {};

return (
Expand Down
114 changes: 44 additions & 70 deletions src/services/interviewApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,16 @@ export interface IResumePresignResponse {
}

/** 자소서 업로드용 프리사인 URL 발급 */
export const getResumePresignUrl = async (
fileName: string,
): Promise<IResumePresignResponse> => {
const response = await apiClient.post(
'/api/presign/resume',
null,
{
params: { fileName },
},
);
export const getResumePresignUrl = async (fileName: string): Promise<IResumePresignResponse> => {
const response = await apiClient.post('/api/presign/resume', null, {
params: { fileName },
});

return unwrapResult<IResumePresignResponse>(response.data);
};

/** S3 공통 업로드 헬퍼 (resume / recording 둘 다 사용) */
export const uploadToS3 = async (
presignedUrl: string,
file: File | Blob,
extraHeaders: Record<string, string> = {},
): Promise<void> => {
export const uploadToS3 = async (presignedUrl: string, file: File | Blob, extraHeaders: Record<string, string> = {}): Promise<void> => {
const baseHeaders: Record<string, string> = {};

// Blob에 type이 있으면 기본 Content-Type으로 사용
Expand Down Expand Up @@ -95,10 +85,10 @@ export const uploadResume = async (file: File): Promise<string> => {
// =====================================================

/** 프론트에서 쓰는 인터뷰 타입 */
export type InterviewType = 'normal' | 'pressure';
export type TInterviewType = 'normal' | 'pressure';

/** BE에서 사용하는 면접 모드 */
export type InterviewMode = 'NORMAL' | 'HARD';
export type TInterviewMode = 'NORMAL' | 'HARD';

/** 프론트에서 쓰는 요청 타입 */
export interface ICreateInterviewSessionRequest {
Expand All @@ -107,12 +97,12 @@ export interface ICreateInterviewSessionRequest {
/** 직무 이름 */
jobTitle: string;
/** 'normal' | 'pressure' → NORMAL | HARD 로 매핑 */
interviewType: InterviewType;
interviewType: TInterviewType;
}

/** BE에 실제로 보내는 payload (mode / jobRole / resumeId) */
interface ICreateInterviewSessionPayload {
mode: InterviewMode;
mode: TInterviewMode;
jobRole: string;
resumeId: string;
}
Expand All @@ -139,11 +129,9 @@ export interface ICreateInterviewSessionResponse {
}

/** 자소서 기반 질문 생성 및 첫번째 질문 조회 */
export const createInterviewSession = async (
data: ICreateInterviewSessionRequest,
): Promise<ICreateInterviewSessionResponse> => {
export const createInterviewSession = async (data: ICreateInterviewSessionRequest): Promise<ICreateInterviewSessionResponse> => {
const resumeId = extractResumeId(data.resumeKey);
const mode: InterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL';
const mode: TInterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL';

const payload: ICreateInterviewSessionPayload = {
mode,
Expand Down Expand Up @@ -185,10 +173,7 @@ export interface IRecordingPresignResponse {
}

/** 녹음 업로드용 프리사인 URL 발급 */
export const getRecordingPresignUrl = async (
questionId: number,
contentType: string,
): Promise<IRecordingPresignResponse> => {
export const getRecordingPresignUrl = async (questionId: number, contentType: string): Promise<IRecordingPresignResponse> => {
const payload: IRecordingPresignRequest = { questionId, contentType };

const response = await apiClient.post('/api/presign/recording', payload);
Expand All @@ -200,31 +185,29 @@ export const getRecordingPresignUrl = async (
// =====================================================

/** 녹음 제출 API 응답 status (현재 스펙상 UPLOADED 고정) */
export type RecordingEnqueueStatus = 'UPLOADED';
export type TRecordingEnqueueStatus = 'UPLOADED';

/** 녹음 저장 응답 (POST /api/questions/{questionId}/recordings) */
export interface ISaveRecordingResponse {
recordingId: number;
status: RecordingEnqueueStatus;
status: TRecordingEnqueueStatus;
}

/** 녹음 저장 및 꼬리질문 생성 API (비동기, 바로 응답) */
export const saveRecording = async (
questionId: number,
): Promise<ISaveRecordingResponse> => {
export const saveRecording = async (questionId: number): Promise<ISaveRecordingResponse> => {
const response = await apiClient.post(`/api/questions/${questionId}/recordings`);
return unwrapResult<ISaveRecordingResponse>(response.data);
};

/** Polling API status */
export type RecordingResultStatus = 'WORKING' | 'READY' | 'FAILED';
export type TRecordingResultStatus = 'WORKING' | 'READY' | 'FAILED';

/** next.type */
export type NextQuestionType = 'FOLLOW_UP' | 'ROOT' | 'NONE';
export type TNextQuestionType = 'FOLLOW_UP' | 'ROOT' | 'NONE';

/** Polling/Timeout 공통 next 객체 타입 */
export interface IRecordingResultNext {
type: NextQuestionType;
type: TNextQuestionType;
nextQuestionId: number | null;
nextQuestionText: string | null;
rootId: number | null;
Expand All @@ -235,14 +218,12 @@ export interface IRecordingResultNext {
/** Polling API 응답 타입 (GET /api/recordings/{recordingId}/results) */
export interface IRecordingResultResponse {
sessionId: number;
status: RecordingResultStatus;
status: TRecordingResultStatus;
next: IRecordingResultNext | null;
}

/** Polling API - 한 번 조회 */
export const getRecordingResult = async (
recordingId: number,
): Promise<IRecordingResultResponse> => {
export const getRecordingResult = async (recordingId: number): Promise<IRecordingResultResponse> => {
const response = await apiClient.get(`/api/recordings/${recordingId}/results`);
return unwrapResult<IRecordingResultResponse>(response.data);
};
Expand All @@ -258,21 +239,17 @@ const mapNextToQuestion = (next: IRecordingResultNext | null): IQuestion | null
return {
questionId: String(next.nextQuestionId),
mainQuestion: isFollowUp
? next.rootText ?? '' // 꼬리질문이면 rootText를 메인 질문으로
: next.nextQuestionText ?? '', // 루트 질문이면 그대로
? (next.rootText ?? '') // 꼬리질문이면 rootText를 메인 질문으로
: (next.nextQuestionText ?? ''), // 루트 질문이면 그대로
subQuestion: isFollowUp
? next.nextQuestionText ?? '' // 꼬리질문 텍스트
? (next.nextQuestionText ?? '') // 꼬리질문 텍스트
: '',
order: next.rootIndex ?? 0,
};
};

/** Polling 헬퍼: READY/FAILED 될 때까지 반복 조회 */
export const pollRecordingResult = async (
recordingId: number,
maxAttempts: number = 60,
intervalMs: number = 3000,
): Promise<IRecordingResultResponse> => {
export const pollRecordingResult = async (recordingId: number, maxAttempts: number = 60, intervalMs: number = 3000): Promise<IRecordingResultResponse> => {
let attempts = 0;

while (attempts < maxAttempts) {
Expand All @@ -297,12 +274,8 @@ export const pollRecordingResult = async (
* 녹음 파일 업로드 + recording 저장 + polling 후
* 다음 질문(IQuestion) 혹은 null(세션 종료) 반환
*/
export const uploadRecordingAndGetNext = async (
questionId: string | number,
audioBlob: Blob,
): Promise<IQuestion | null> => {
const numericQuestionId =
typeof questionId === 'string' ? Number(questionId) : questionId;
export const uploadRecordingAndGetNext = async (questionId: string | number, audioBlob: Blob): Promise<IQuestion | null> => {
const numericQuestionId = typeof questionId === 'string' ? Number(questionId) : questionId;

if (Number.isNaN(numericQuestionId)) {
throw new Error('유효하지 않은 questionId 입니다.');
Expand All @@ -311,10 +284,7 @@ export const uploadRecordingAndGetNext = async (
const contentType = (audioBlob as any).type || 'audio/webm';

// 1) Presign 발급
const { uploadUrl, requiredHeaders } = await getRecordingPresignUrl(
numericQuestionId,
contentType,
);
const { uploadUrl, requiredHeaders } = await getRecordingPresignUrl(numericQuestionId, contentType);

// 2) S3 업로드
await uploadToS3(uploadUrl, audioBlob, requiredHeaders);
Expand Down Expand Up @@ -345,9 +315,7 @@ export interface ITimeoutResponse {
}

/** 사용자가 시간초과로 답변하지 못한 경우 - Timeout API 호출 */
export const sendTimeout = async (
questionId: string | number,
): Promise<ITimeoutResponse> => {
export const sendTimeout = async (questionId: string | number): Promise<ITimeoutResponse> => {
const response = await apiClient.post(`/api/questions/${questionId}/timeout`);
return unwrapResult<ITimeoutResponse>(response.data);
};
Expand All @@ -357,9 +325,7 @@ export const sendTimeout = async (
* - next.type === ROOT → 다음 루트 질문
* - next.type === NONE → 더 이상 질문 없음 (최종 피드백 조회)
*/
export const timeoutAndGetNextQuestion = async (
questionId: string | number,
): Promise<IQuestion | null> => {
export const timeoutAndGetNextQuestion = async (questionId: string | number): Promise<IQuestion | null> => {
const result = await sendTimeout(questionId);

if (!result.next || result.next.type === 'NONE') {
Expand All @@ -374,14 +340,14 @@ export const timeoutAndGetNextQuestion = async (
// =====================================================

/** 피드백 생성 진행 상태 */
export type FeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED';
export type TFeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED';

/** QnA 턴 타입 */
export type FeedbackTurnType = 'QUESTION' | 'ANSWER';
export type TFeedbackTurnType = 'QUESTION' | 'ANSWER';

/** 한 턴 (질문 / 답변) */
export interface IQnaTurn {
turn: FeedbackTurnType;
turn: TFeedbackTurnType;
content: string;
}

Expand All @@ -403,14 +369,22 @@ export interface IInterviewSummary {

/** 최종 피드백 조회 응답 */
export interface IFinalFeedbackResponse {
feedbackProgressStatus: FeedbackProgressStatus;
feedbackProgressStatus: TFeedbackProgressStatus;
interviewSummary: IInterviewSummary | null; // WORKING일 때는 null
feedbacks: {
feedbackType: 'positive' | 'improvement';
answer: string;
timeout: boolean;
feedback: string;
questionId: number;
question: string;
}[];
totalQuestions: number;
timeoutCount: number;
}

/** 최종 피드백 조회 (GET /api/interview-sesisons/{sessionId}) */
export const getFinalFeedback = async (
sessionId: string | number,
): Promise<IFinalFeedbackResponse> => {
export const getFinalFeedback = async (sessionId: string | number): Promise<IFinalFeedbackResponse> => {
const response = await apiClient.get(`/api/interview-sesisons/${sessionId}`);
return unwrapResult<IFinalFeedbackResponse>(response.data);
};