Skip to content
Open
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
86 changes: 86 additions & 0 deletions jobdri/src/lib/api/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { clearAuthTokens, getAuthHeaders, API_BASE_URL } from "@/lib/auth";
import { ROUTES } from "@/constants/routes";

export { API_BASE_URL, getAuthHeaders };

export interface ApiResponse<T> {
isSuccess: boolean;
code: string;
message: string;
result: T | null;
error: string | null;
}

export class UnauthorizedError extends Error {
constructor() {
super("인증이 만료되었습니다. 다시 로그인해주세요.");
this.name = "UnauthorizedError";
}
}

export function handleUnauthorized(): never {
clearAuthTokens();

if (typeof window !== "undefined") {
const redirectPath = encodeURIComponent(window.location.pathname);
window.location.replace(`${ROUTES.LOGIN}?redirect=${redirectPath}`);
}

throw new UnauthorizedError();
}
Comment on lines +21 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve query string and hash in the redirect path.

window.location.pathname drops the current ?query and #hash. Since the goal is to return the user to the exact page after login, pages that rely on query params will lose state on redirect-back.

🔧 Proposed fix
   if (typeof window !== "undefined") {
-    const redirectPath = encodeURIComponent(window.location.pathname);
+    const redirectPath = encodeURIComponent(
+      window.location.pathname + window.location.search + window.location.hash,
+    );
     window.location.replace(`${ROUTES.LOGIN}?redirect=${redirectPath}`);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function handleUnauthorized(): never {
clearAuthTokens();
if (typeof window !== "undefined") {
const redirectPath = encodeURIComponent(window.location.pathname);
window.location.replace(`${ROUTES.LOGIN}?redirect=${redirectPath}`);
}
throw new UnauthorizedError();
}
export function handleUnauthorized(): never {
clearAuthTokens();
if (typeof window !== "undefined") {
const redirectPath = encodeURIComponent(
window.location.pathname + window.location.search + window.location.hash,
);
window.location.replace(`${ROUTES.LOGIN}?redirect=${redirectPath}`);
}
throw new UnauthorizedError();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/lib/api/client.ts` around lines 21 - 30, handleUnauthorized
currently builds redirectPath using window.location.pathname which drops ?query
and `#hash`; change how redirectPath is composed in the handleUnauthorized
function to include window.location.search and window.location.hash (e.g. const
redirectPath = encodeURIComponent(window.location.pathname +
window.location.search + window.location.hash)) before calling
window.location.replace(`${ROUTES.LOGIN}?redirect=${redirectPath}`) so the full
original URL (path, query, and hash) is preserved when redirecting to
ROUTES.LOGIN.


export async function parseApiResponse<T>(
response: Response,
fallbackMessage: string,
): Promise<T> {
if (response.status === 401) {
handleUnauthorized();
}

let data: ApiResponse<T> | null = null;

try {
data = (await response.json()) as ApiResponse<T>;
} catch {
throw new Error(`${fallbackMessage} 응답을 확인할 수 없습니다.`);
}

if (!response.ok || !data.isSuccess || !data.result) {
throw new Error(data?.error || data?.message || fallbackMessage);
Comment on lines +48 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

!data.result rejects valid falsy results, and data may be null here.

Two concerns on the validation check:

  1. !data.result treats legitimate falsy values (0, false, "") as failures and throws even when isSuccess is true. Use an explicit null/undefined check instead.
  2. response.json() can resolve to null (literal JSON null body), so data may be null; !data.isSuccess would then throw a TypeError. The optional chaining on line 49 (data?.error) suggests this guard was intended but is missing on line 48.
🔧 Proposed fix
-  if (!response.ok || !data.isSuccess || !data.result) {
+  if (!response.ok || !data?.isSuccess || data.result == null) {
     throw new Error(data?.error || data?.message || fallbackMessage);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/lib/api/client.ts` around lines 48 - 49, The current validation
wrongly treats legitimate falsy results as failures and can throw when data is
null; update the check in the response handling so it uses safe null checks and
explicit undefined/null checks: replace the condition `if (!response.ok ||
!data.isSuccess || !data.result)` with something like `if (!response.ok || data
== null || data.isSuccess === false || data.result == null)` (use optional
chaining where appropriate, e.g., `data == null` and `data.isSuccess === false`,
and check `data.result == null` instead of `!data.result`) so that `response`,
`data`, `isSuccess`, and `result` are validated correctly in the function that
parses `response.json()` in client.ts.

}

return data.result;
}

export async function parseApiResponseAllowNull<T>(
response: Response,
fallbackMessage: string,
): Promise<T | null> {
if (response.status === 401) {
handleUnauthorized();
}

const responseText = await response.text();

if (!responseText.trim()) {
if (!response.ok) {
throw new Error(fallbackMessage);
}

return null;
}

let data: ApiResponse<T> | null = null;

try {
data = JSON.parse(responseText) as ApiResponse<T>;
} catch {
throw new Error(`${fallbackMessage} 응답을 확인할 수 없습니다.`);
}

if (!response.ok || !data.isSuccess) {
throw new Error(data?.error || data?.message || fallbackMessage);
Comment on lines +81 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Same potential null dereference as parseApiResponse.

JSON.parse("null") returns null, so data can be null here while responseText is non-empty. !data.isSuccess on line 81 would throw a TypeError, despite line 82 using data?.. Apply the same optional-chaining guard.

🔧 Proposed fix
-  if (!response.ok || !data.isSuccess) {
+  if (!response.ok || !data?.isSuccess) {
     throw new Error(data?.error || data?.message || fallbackMessage);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!response.ok || !data.isSuccess) {
throw new Error(data?.error || data?.message || fallbackMessage);
if (!response.ok || !data?.isSuccess) {
throw new Error(data?.error || data?.message || fallbackMessage);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/lib/api/client.ts` around lines 81 - 82, The conditional can throw
if data is null (JSON.parse can return null); update the guard to use optional
chaining — change the check from if (!response.ok || !data.isSuccess) to if
(!response.ok || !data?.isSuccess) and keep using data?.error || data?.message
|| fallbackMessage when throwing so the code safely handles data === null;
locate this in client.ts around the response/data handling (same area as
parseApiResponse logic) and apply the optional chaining fix.

}

return data.result;
}
17 changes: 11 additions & 6 deletions jobdri/src/lib/api/credit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getAuthHeaders } from "../auth";
import { getAuthHeaders, handleUnauthorized } from "@/lib/api/client";

const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL;

Expand All @@ -19,11 +19,16 @@ interface ApiResponse<T> {
error: string | null;
}

function checkResponse(response: Response, fallbackMessage: string): void {
if (response.status === 401) handleUnauthorized();
if (!response.ok) throw new Error(fallbackMessage);
}

export async function fetchCreditBalance(): Promise<number> {
const response = await fetch(`${BASE_URL}/api/payments/credits/me`, {
headers: getAuthHeaders(),
});
if (!response.ok) throw new Error("크레딧 잔액 조회에 실패했습니다.");
checkResponse(response, "크레딧 잔액 조회에 실패했습니다.");
const { result }: ApiResponse<{ creditBalance: number }> =
await response.json();
return result.creditBalance;
Expand All @@ -38,7 +43,7 @@ export async function fetchCreditTransactions(
const response = await fetch(url.toString(), {
headers: getAuthHeaders(),
});
if (!response.ok) throw new Error("크레딧 거래 내역 조회에 실패했습니다.");
checkResponse(response, "크레딧 거래 내역 조회에 실패했습니다.");
const { result }: ApiResponse<CreditTransaction[]> = await response.json();
return result;
}
Expand All @@ -57,7 +62,7 @@ export async function fetchCreditPlans(): Promise<CreditPlan[]> {
const response = await fetch(`${BASE_URL}/api/payments/plans`, {
headers: getAuthHeaders(),
});
if (!response.ok) throw new Error("크레딧 플랜 조회에 실패했습니다.");
checkResponse(response, "크레딧 플랜 조회에 실패했습니다.");
const { result }: ApiResponse<CreditPlan[]> = await response.json();
return result;
}
Expand All @@ -83,7 +88,7 @@ export async function preparePurchase(
},
body: JSON.stringify({ planCode }),
});
if (!response.ok) throw new Error("결제 준비에 실패했습니다.");
checkResponse(response, "결제 준비에 실패했습니다.");
const { result }: ApiResponse<PreparePaymentResult> = await response.json();
return result;
}
Expand All @@ -101,5 +106,5 @@ export async function confirmPurchase(
},
body: JSON.stringify({ paymentKey, orderId, amount }),
});
if (!response.ok) throw new Error("결제 승인에 실패했습니다.");
checkResponse(response, "결제 승인에 실패했습니다.");
}
66 changes: 6 additions & 60 deletions jobdri/src/lib/api/jobPostings.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { API_BASE_URL, AUTH_STORAGE_KEYS } from "@/lib/auth";

interface ApiResponse<T> {
isSuccess: boolean;
code: string;
message: string;
result: T | null;
error: string | null;
}
import {
API_BASE_URL,
getAuthHeaders,
parseApiResponse,
parseApiResponseAllowNull,
} from "@/lib/api/client";

export interface JobPostingExtracted {
companyName: string;
Expand Down Expand Up @@ -117,14 +114,6 @@ interface TimedRequestInit extends RequestInit {

const DEFAULT_REQUEST_TIMEOUT_MS = 30000;

function getAuthHeaders(): Record<string, string> {
const token =
typeof window !== "undefined"
? window.localStorage.getItem(AUTH_STORAGE_KEYS.accessToken)
: null;

return token ? { Authorization: `Bearer ${token}` } : {};
}

function getImageContentType(file: File) {
if (file.type) {
Expand Down Expand Up @@ -202,49 +191,6 @@ async function fetchWithTimeout(
}
}

async function parseApiResponse<T>(response: Response, fallbackMessage: string) {
let data: ApiResponse<T> | null = null;

try {
data = (await response.json()) as ApiResponse<T>;
} catch {
throw new Error(`${fallbackMessage} 응답을 확인할 수 없습니다.`);
}

if (!response.ok || !data.isSuccess || !data.result) {
throw new Error(data?.error || data?.message || fallbackMessage);
}

return data.result;
}

async function parseApiResponseAllowNull<T>(
response: Response,
fallbackMessage: string,
) {
let data: ApiResponse<T> | null = null;
const responseText = await response.text();

if (!responseText.trim()) {
if (!response.ok) {
throw new Error(fallbackMessage);
}

return null;
}

try {
data = JSON.parse(responseText) as ApiResponse<T>;
} catch {
throw new Error(`${fallbackMessage} 응답을 확인할 수 없습니다.`);
}

if (!response.ok || !data.isSuccess) {
throw new Error(data?.error || data?.message || fallbackMessage);
}

return data.result;
}

async function postJobPosting<T>(
path: string,
Expand Down
38 changes: 1 addition & 37 deletions jobdri/src/lib/api/mockApplies.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
import { API_BASE_URL, AUTH_STORAGE_KEYS } from "@/lib/auth";

interface ApiResponse<T> {
isSuccess: boolean;
code: string;
message: string;
result: T | null;
error: string | null;
}
import { API_BASE_URL, getAuthHeaders, parseApiResponse } from "@/lib/api/client";

export type JobPostingApplyType = "MOCK" | "ACTUAL";
export type MockApplyProgressStatus =
Expand Down Expand Up @@ -58,34 +50,6 @@ export interface MockApplyHomeList {
export const APPLY_TYPE_STORAGE_KEY = "jobdri.applyType";
const MOCK_APPLY_RESUME_STORAGE_KEY = "jobdri.mockApplyResumeRecords";

function getAuthHeaders(): Record<string, string> {
const token =
typeof window !== "undefined"
? window.localStorage.getItem(AUTH_STORAGE_KEYS.accessToken)
: null;

return token ? { Authorization: `Bearer ${token}` } : {};
}

async function parseApiResponse<T>(
response: Response,
fallbackMessage: string,
) {
let data: ApiResponse<T> | null = null;

try {
data = (await response.json()) as ApiResponse<T>;
} catch {
throw new Error(`${fallbackMessage} 응답을 확인할 수 없습니다.`);
}

if (!response.ok || !data.isSuccess || !data.result) {
throw new Error(data?.error || data?.message || fallbackMessage);
}

return data.result;
}

async function postMockApplyFromJobPosting(
path: string,
jobPostingId: number,
Expand Down
28 changes: 1 addition & 27 deletions jobdri/src/lib/api/questions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { API_BASE_URL, getAuthHeaders } from "@/lib/auth";
import { API_BASE_URL, getAuthHeaders, parseApiResponse } from "@/lib/api/client";

export interface QuestionItem {
id: string;
Expand All @@ -20,14 +20,6 @@ interface QuestionApiItem {
answer?: string;
}

interface ApiResponse<T> {
isSuccess: boolean;
code: string;
message: string;
result: T | null;
error: string | null;
}

interface SelectedQuestionsApiResponse {
mockApplyId: number;
status: string;
Expand All @@ -39,24 +31,6 @@ export interface AnswerItem {
answer: string;
}

async function parseApiResponse<T>(
response: Response,
fallbackMessage: string,
) {
let data: ApiResponse<T> | null = null;

try {
data = (await response.json()) as ApiResponse<T>;
} catch {
throw new Error(`${fallbackMessage} 응답을 확인할 수 없습니다.`);
}

if (!response.ok || !data.isSuccess) {
throw new Error(data?.error || data?.message || fallbackMessage);
}

return data.result;
}

export async function fetchQuestions(
mockApplyId: number,
Expand Down
24 changes: 2 additions & 22 deletions jobdri/src/lib/api/result.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { API_BASE_URL, getAuthHeaders } from "@/lib/auth";
import { API_BASE_URL, getAuthHeaders, parseApiResponse as parseApiResponseBase } from "@/lib/api/client";

export class CreditInsufficientError extends Error {
constructor() {
Expand Down Expand Up @@ -45,14 +45,6 @@ export interface AnalysisResult {
questions: AnalysisQuestion[];
}

interface ApiResponse<T> {
isSuccess: boolean;
code: string;
message: string;
result: T | null;
error: string | null;
}

async function parseApiResponse<T>(
response: Response,
fallbackMessage: string,
Expand All @@ -61,19 +53,7 @@ async function parseApiResponse<T>(
throw new CreditInsufficientError();
}

let data: ApiResponse<T> | null = null;

try {
data = (await response.json()) as ApiResponse<T>;
} catch {
throw new Error(`${fallbackMessage} 응답을 확인할 수 없습니다.`);
}

if (!response.ok || !data.isSuccess || !data.result) {
throw new Error(data?.error || data?.message || fallbackMessage);
}

return data.result;
return parseApiResponseBase<T>(response, fallbackMessage);
}

export async function fetchSequence(
Expand Down