refactor(WEB): Server Action 공통화 및 보안 강화
- executeServerAction 공통 유틸 도입으로 actions.ts 대폭 간소화 (50+개 파일) - sanitize 유틸 추가 (XSS 방지) - middleware CSP 헤더 추가 및 Open Redirect 방지 - 프록시 라우트 로깅 개발환경 한정으로 변경 - 프로덕션 불필요 console.log 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import type { Rank } from './types';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
interface PositionApiData {
|
||||
id: number;
|
||||
@@ -17,12 +18,6 @@ interface PositionApiData {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// ===== 데이터 변환: API → Frontend =====
|
||||
function transformApiToFrontend(apiData: PositionApiData): Rank {
|
||||
return {
|
||||
@@ -39,55 +34,21 @@ function transformApiToFrontend(apiData: PositionApiData): Rank {
|
||||
export async function getRanks(params?: {
|
||||
is_active?: boolean;
|
||||
q?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: Rank[];
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('type', 'rank');
|
||||
|
||||
if (params?.is_active !== undefined) {
|
||||
searchParams.set('is_active', params.is_active.toString());
|
||||
}
|
||||
if (params?.q) {
|
||||
searchParams.set('q', params.q);
|
||||
}
|
||||
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions?${searchParams.toString()}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '직급 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<PositionApiData[]> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '직급 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const ranks = result.data.map(transformApiToFrontend);
|
||||
return { success: true, data: ranks };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[getRanks] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}): Promise<ActionResult<Rank[]>> {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('type', 'rank');
|
||||
if (params?.is_active !== undefined) {
|
||||
searchParams.set('is_active', params.is_active.toString());
|
||||
}
|
||||
if (params?.q) {
|
||||
searchParams.set('q', params.q);
|
||||
}
|
||||
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/positions?${searchParams.toString()}`,
|
||||
transform: (data: PositionApiData[]) => data.map(transformApiToFrontend),
|
||||
errorMessage: '직급 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 직급 생성 =====
|
||||
@@ -95,51 +56,19 @@ export async function createRank(data: {
|
||||
name: string;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: Rank;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: 'rank',
|
||||
name: data.name,
|
||||
sort_order: data.sort_order,
|
||||
is_active: data.is_active ?? true,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '직급 생성에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<PositionApiData> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '직급 생성에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const rank = transformApiToFrontend(result.data);
|
||||
return { success: true, data: rank };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[createRank] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}): Promise<ActionResult<Rank>> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/positions`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'rank',
|
||||
name: data.name,
|
||||
sort_order: data.sort_order,
|
||||
is_active: data.is_active ?? true,
|
||||
},
|
||||
transform: transformApiToFrontend,
|
||||
errorMessage: '직급 생성에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 직급 수정 =====
|
||||
@@ -150,127 +79,33 @@ export async function updateRank(
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: Rank;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '직급 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result: ApiResponse<PositionApiData> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '직급 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const rank = transformApiToFrontend(result.data);
|
||||
return { success: true, data: rank };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[updateRank] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
): Promise<ActionResult<Rank>> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/positions/${id}`,
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
transform: transformApiToFrontend,
|
||||
errorMessage: '직급 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 직급 삭제 =====
|
||||
export async function deleteRank(id: number): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '직급 삭제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '직급 삭제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[deleteRank] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
export async function deleteRank(id: number): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/positions/${id}`,
|
||||
method: 'DELETE',
|
||||
errorMessage: '직급 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 직급 순서 변경 =====
|
||||
export async function reorderRanks(
|
||||
items: { id: number; sort_order: number }[]
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions/reorder`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ items }),
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
__authError: error.code === 'UNAUTHORIZED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '순서 변경에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '순서 변경에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[reorderRanks] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/positions/reorder`,
|
||||
method: 'PUT',
|
||||
body: { items },
|
||||
errorMessage: '순서 변경에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user