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:
@@ -13,7 +13,7 @@
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import type {
|
||||
ExpenseEstimateItem,
|
||||
ApprovalPerson,
|
||||
@@ -125,6 +125,8 @@ function transformEmployee(employee: EmployeeApiData): ApprovalPerson {
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
/**
|
||||
* 파일 업로드
|
||||
* @param files 업로드할 파일 배열
|
||||
@@ -200,88 +202,36 @@ export async function getExpenseEstimateItems(yearMonth?: string): Promise<{
|
||||
accountBalance: number;
|
||||
finalDifference: number;
|
||||
} | null> {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
const searchParams = new URLSearchParams();
|
||||
if (yearMonth) searchParams.set('year_month', yearMonth);
|
||||
|
||||
if (yearMonth) {
|
||||
searchParams.set('year_month', yearMonth);
|
||||
}
|
||||
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/reports/expense-estimate?${searchParams.toString()}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[DocumentCreateActions] GET expense-estimate error:', error?.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[DocumentCreateActions] GET expense-estimate error:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: ApiResponse<ExpenseEstimateApiResponse> = await response.json();
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
console.warn('[DocumentCreateActions] No data in response');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
items: result.data.items.map(transformExpenseEstimateItem),
|
||||
totalExpense: result.data.total_expense,
|
||||
accountBalance: result.data.account_balance,
|
||||
finalDifference: result.data.final_difference,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] getExpenseEstimateItems error:', error);
|
||||
return null;
|
||||
}
|
||||
const result = await executeServerAction<ExpenseEstimateApiResponse>({
|
||||
url: `${API_URL}/api/v1/reports/expense-estimate?${searchParams.toString()}`,
|
||||
errorMessage: '비용견적서 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return null;
|
||||
return {
|
||||
items: result.data.items.map(transformExpenseEstimateItem),
|
||||
totalExpense: result.data.total_expense,
|
||||
accountBalance: result.data.account_balance,
|
||||
finalDifference: result.data.final_difference,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 목록 조회 (결재선/참조 선택용)
|
||||
*/
|
||||
export async function getEmployees(search?: string): Promise<ApprovalPerson[]> {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('per_page', '100');
|
||||
if (search) {
|
||||
searchParams.set('search', search);
|
||||
}
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('per_page', '100');
|
||||
if (search) searchParams.set('search', search);
|
||||
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees?${searchParams.toString()}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[DocumentCreateActions] GET employees error:', error?.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[DocumentCreateActions] GET employees error:', response.status);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: ApiResponse<{ data: EmployeeApiData[] }> = await response.json();
|
||||
|
||||
if (!result.success || !result.data?.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.data.data.map(transformEmployee);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] getEmployees error:', error);
|
||||
return [];
|
||||
}
|
||||
const result = await executeServerAction<{ data: EmployeeApiData[] }>({
|
||||
url: `${API_URL}/api/v1/employees?${searchParams.toString()}`,
|
||||
errorMessage: '직원 목록 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data?.data) return [];
|
||||
return result.data.data.map(transformEmployee);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,80 +242,47 @@ export async function createApproval(formData: DocumentFormData): Promise<{
|
||||
data?: { id: number; documentNo: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 새 첨부파일 업로드
|
||||
const newFiles = formData.proposalData?.attachments
|
||||
|| formData.expenseReportData?.attachments
|
||||
|| [];
|
||||
let uploadedFiles: UploadedFile[] = [];
|
||||
// 새 첨부파일 업로드
|
||||
const newFiles = formData.proposalData?.attachments
|
||||
|| formData.expenseReportData?.attachments
|
||||
|| [];
|
||||
let uploadedFiles: UploadedFile[] = [];
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
const uploadResult = await uploadFiles(newFiles);
|
||||
if (!uploadResult.success) {
|
||||
return { success: false, error: uploadResult.error };
|
||||
}
|
||||
uploadedFiles = uploadResult.data || [];
|
||||
if (newFiles.length > 0) {
|
||||
const uploadResult = await uploadFiles(newFiles);
|
||||
if (!uploadResult.success) {
|
||||
return { success: false, error: uploadResult.error };
|
||||
}
|
||||
|
||||
// 프론트엔드 데이터 → API 요청 데이터 변환
|
||||
const requestBody = {
|
||||
form_code: formData.basicInfo.documentType,
|
||||
title: getDocumentTitle(formData),
|
||||
status: 'draft', // 임시저장
|
||||
steps: [
|
||||
...formData.approvalLine.map((person, index) => ({
|
||||
step_type: 'approval',
|
||||
step_order: index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
...formData.references.map((person, index) => ({
|
||||
step_type: 'reference',
|
||||
step_order: formData.approvalLine.length + index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
],
|
||||
content: getDocumentContent(formData, uploadedFiles),
|
||||
};
|
||||
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '문서 저장에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result: ApiResponse<ApprovalCreateResponse> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '문서 저장에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: result.data.id,
|
||||
documentNo: result.data.document_number,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] createApproval error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
uploadedFiles = uploadResult.data || [];
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
form_code: formData.basicInfo.documentType,
|
||||
title: getDocumentTitle(formData),
|
||||
status: 'draft',
|
||||
steps: [
|
||||
...formData.approvalLine.map((person, index) => ({
|
||||
step_type: 'approval',
|
||||
step_order: index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
...formData.references.map((person, index) => ({
|
||||
step_type: 'reference',
|
||||
step_order: formData.approvalLine.length + index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
],
|
||||
content: getDocumentContent(formData, uploadedFiles),
|
||||
};
|
||||
|
||||
const result = await executeServerAction<ApprovalCreateResponse>({
|
||||
url: `${API_URL}/api/v1/approvals`,
|
||||
method: 'POST',
|
||||
body: requestBody,
|
||||
errorMessage: '문서 저장에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, data: { id: result.data.id, documentNo: result.data.document_number } };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,40 +292,13 @@ export async function submitApproval(id: number): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '문서 상신에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
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('[DocumentCreateActions] submitApproval error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/approvals/${id}/submit`,
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '문서 상신에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -457,41 +347,13 @@ export async function getApprovalById(id: number): Promise<{
|
||||
data?: DocumentFormData;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, error: error?.message || '문서 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return { success: false, error: '문서를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: false, error: '문서 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.message || '문서 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
// API 응답을 프론트엔드 형식으로 변환
|
||||
const apiData = result.data;
|
||||
const formDataResult = transformApiToFormData(apiData);
|
||||
|
||||
return { success: true, data: formDataResult };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] getApprovalById error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await executeServerAction<any>({
|
||||
url: `${API_URL}/api/v1/approvals/${id}`,
|
||||
errorMessage: '문서 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, data: transformApiToFormData(result.data) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -502,75 +364,46 @@ export async function updateApproval(id: number, formData: DocumentFormData): Pr
|
||||
data?: { id: number; documentNo: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 새 첨부파일 업로드
|
||||
const newFiles = formData.proposalData?.attachments
|
||||
|| formData.expenseReportData?.attachments
|
||||
|| [];
|
||||
let uploadedFiles: UploadedFile[] = [];
|
||||
// 새 첨부파일 업로드
|
||||
const newFiles = formData.proposalData?.attachments
|
||||
|| formData.expenseReportData?.attachments
|
||||
|| [];
|
||||
let uploadedFiles: UploadedFile[] = [];
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
const uploadResult = await uploadFiles(newFiles);
|
||||
if (!uploadResult.success) {
|
||||
return { success: false, error: uploadResult.error };
|
||||
}
|
||||
uploadedFiles = uploadResult.data || [];
|
||||
if (newFiles.length > 0) {
|
||||
const uploadResult = await uploadFiles(newFiles);
|
||||
if (!uploadResult.success) {
|
||||
return { success: false, error: uploadResult.error };
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
form_code: formData.basicInfo.documentType,
|
||||
title: getDocumentTitle(formData),
|
||||
steps: [
|
||||
...formData.approvalLine.map((person, index) => ({
|
||||
step_type: 'approval',
|
||||
step_order: index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
...formData.references.map((person, index) => ({
|
||||
step_type: 'reference',
|
||||
step_order: formData.approvalLine.length + index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
],
|
||||
content: getDocumentContent(formData, uploadedFiles),
|
||||
};
|
||||
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(requestBody),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '문서 수정에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '문서 수정에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: result.data.id,
|
||||
documentNo: result.data.document_number,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] updateApproval error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
uploadedFiles = uploadResult.data || [];
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
form_code: formData.basicInfo.documentType,
|
||||
title: getDocumentTitle(formData),
|
||||
steps: [
|
||||
...formData.approvalLine.map((person, index) => ({
|
||||
step_type: 'approval',
|
||||
step_order: index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
...formData.references.map((person, index) => ({
|
||||
step_type: 'reference',
|
||||
step_order: formData.approvalLine.length + index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
],
|
||||
content: getDocumentContent(formData, uploadedFiles),
|
||||
};
|
||||
|
||||
const result = await executeServerAction<ApprovalCreateResponse>({
|
||||
url: `${API_URL}/api/v1/approvals/${id}`,
|
||||
method: 'PATCH',
|
||||
body: requestBody,
|
||||
errorMessage: '문서 수정에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, data: { id: result.data.id, documentNo: result.data.document_number } };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -615,39 +448,12 @@ export async function deleteApproval(id: number): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '문서 삭제에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
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('[DocumentCreateActions] deleteApproval error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/approvals/${id}`,
|
||||
method: 'DELETE',
|
||||
errorMessage: '문서 삭제에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
||||
Reference in New Issue
Block a user