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:
유병철
2026-02-09 16:14:06 +09:00
parent d014227e9c
commit 55e0791e16
85 changed files with 7211 additions and 17638 deletions

View File

@@ -10,20 +10,14 @@
'use server';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { ReferenceRecord, ApprovalType, DocumentStatus } from './types';
// ============================================
// API 응답 타입 정의
// ============================================
interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
}
interface PaginatedResponse<T> {
current_page: number;
data: T[];
@@ -32,24 +26,13 @@ interface PaginatedResponse<T> {
last_page: number;
}
// API 응답의 참조 문서 타입
interface ReferenceApiData {
id: number;
document_number: string;
title: string;
status: string;
form?: {
id: number;
name: string;
code: string;
category: string;
};
drafter?: {
id: number;
name: string;
position?: string;
department?: { name: string };
};
form?: { id: number; name: string; code: string; category: string };
drafter?: { id: number; name: string; position?: string; department?: { name: string } };
steps?: ReferenceStepApiData[];
created_at: string;
updated_at: string;
@@ -60,12 +43,7 @@ interface ReferenceStepApiData {
step_order: number;
step_type: string;
approver_id: number;
approver?: {
id: number;
name: string;
position?: string;
department?: { name: string };
};
approver?: { id: number; name: string; position?: string; department?: { name: string } };
is_read: boolean;
read_at?: string;
}
@@ -74,37 +52,22 @@ interface ReferenceStepApiData {
// 헬퍼 함수
// ============================================
/**
* API 상태 → 프론트엔드 상태 변환
*/
function mapApiStatus(apiStatus: string): DocumentStatus {
const statusMap: Record<string, DocumentStatus> = {
'draft': 'pending',
'pending': 'pending',
'in_progress': 'pending',
'approved': 'approved',
'rejected': 'rejected',
'draft': 'pending', 'pending': 'pending', 'in_progress': 'pending',
'approved': 'approved', 'rejected': 'rejected',
};
return statusMap[apiStatus] || 'pending';
}
/**
* 양식 카테고리 → 결재 유형 변환
*/
function mapApprovalType(formCategory?: string): ApprovalType {
const typeMap: Record<string, ApprovalType> = {
'expense_report': 'expense_report',
'proposal': 'proposal',
'expense_estimate': 'expense_estimate',
'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate',
};
return typeMap[formCategory || ''] || 'proposal';
}
/**
* API 데이터 → 프론트엔드 데이터 변환
*/
function transformApiToFrontend(data: ReferenceApiData): ReferenceRecord {
// 참조 단계에서 열람 상태 추출
const referenceStep = data.steps?.find(s => s.step_type === 'reference');
const isRead = referenceStep?.is_read ?? false;
const readAt = referenceStep?.read_at;
@@ -126,219 +89,91 @@ function transformApiToFrontend(data: ReferenceApiData): ReferenceRecord {
};
}
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ============================================
// API 함수
// ============================================
/**
* 참조함 목록 조회
*/
export async function getReferences(params?: {
page?: number;
per_page?: number;
search?: string;
is_read?: boolean;
approval_type?: string;
sort_by?: string;
sort_dir?: 'asc' | 'desc';
page?: number; per_page?: number; search?: string; is_read?: boolean;
approval_type?: string; sort_by?: string; sort_dir?: 'asc' | 'desc';
}): Promise<{ data: ReferenceRecord[]; total: number; lastPage: number }> {
try {
const searchParams = new URLSearchParams();
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.per_page) searchParams.set('per_page', String(params.per_page));
if (params?.search) searchParams.set('search', params.search);
if (params?.is_read !== undefined) searchParams.set('is_read', params.is_read ? '1' : '0');
if (params?.approval_type && params.approval_type !== 'all') searchParams.set('approval_type', params.approval_type);
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
if (params?.page) searchParams.set('page', String(params.page));
if (params?.per_page) searchParams.set('per_page', String(params.per_page));
if (params?.search) searchParams.set('search', params.search);
if (params?.is_read !== undefined) {
searchParams.set('is_read', params.is_read ? '1' : '0');
}
if (params?.approval_type && params.approval_type !== 'all') {
searchParams.set('approval_type', params.approval_type);
}
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
const result = await executeServerAction<PaginatedResponse<ReferenceApiData>>({
url: `${API_URL}/api/v1/approvals/reference?${searchParams.toString()}`,
errorMessage: '참조 목록 조회에 실패했습니다.',
});
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/reference?${searchParams.toString()}`;
const { response, error } = await serverFetch(url, {
method: 'GET',
});
// serverFetch handles 401 with redirect, so we just check for other errors
if (error || !response) {
console.error('[ReferenceBoxActions] GET reference error:', error?.message);
return { data: [], total: 0, lastPage: 1 };
}
if (!response.ok) {
console.error('[ReferenceBoxActions] GET reference error:', response.status);
return { data: [], total: 0, lastPage: 1 };
}
const result: ApiResponse<PaginatedResponse<ReferenceApiData>> = await response.json();
if (!result.success || !result.data?.data) {
console.warn('[ReferenceBoxActions] No data in response');
return { data: [], total: 0, lastPage: 1 };
}
return {
data: result.data.data.map(transformApiToFrontend),
total: result.data.total,
lastPage: result.data.last_page,
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ReferenceBoxActions] getReferences error:', error);
if (!result.success || !result.data?.data) {
return { data: [], total: 0, lastPage: 1 };
}
return {
data: result.data.data.map(transformApiToFrontend),
total: result.data.total,
lastPage: result.data.last_page,
};
}
/**
* 참조함 통계 (목록 데이터 기반)
*/
export async function getReferenceSummary(): Promise<{ all: number; read: number; unread: number } | null> {
try {
// 전체 데이터를 조회해서 통계 계산
const allResult = await getReferences({ per_page: 1 });
const readResult = await getReferences({ per_page: 1, is_read: true });
const unreadResult = await getReferences({ per_page: 1, is_read: false });
return {
all: allResult.total,
read: readResult.total,
unread: unreadResult.total,
};
return { all: allResult.total, read: readResult.total, unread: unreadResult.total };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ReferenceBoxActions] getReferenceSummary error:', error);
return null;
}
}
/**
* 열람 처리
*/
export async function markAsRead(id: string): Promise<{ success: boolean; error?: string }> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/read`;
const { response, error } = await serverFetch(url, {
method: 'POST',
body: JSON.stringify({}),
});
// serverFetch handles 401 with redirect
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('[ReferenceBoxActions] markAsRead error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
export async function markAsRead(id: string): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/approvals/${id}/read`,
method: 'POST',
body: {},
errorMessage: '열람 처리에 실패했습니다.',
});
}
/**
* 미열람 처리
*/
export async function markAsUnread(id: string): Promise<{ success: boolean; error?: string }> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/unread`;
const { response, error } = await serverFetch(url, {
method: 'POST',
body: JSON.stringify({}),
});
// serverFetch handles 401 with redirect
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('[ReferenceBoxActions] markAsUnread error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
export async function markAsUnread(id: string): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/approvals/${id}/unread`,
method: 'POST',
body: {},
errorMessage: '미열람 처리에 실패했습니다.',
});
}
/**
* 일괄 열람 처리
*/
export async function markAsReadBulk(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
const failedIds: string[] = [];
for (const id of ids) {
const result = await markAsRead(id);
if (!result.success) {
failedIds.push(id);
}
if (!result.success) failedIds.push(id);
}
if (failedIds.length > 0) {
return {
success: false,
failedIds,
error: `${failedIds.length}건의 열람 처리에 실패했습니다.`,
};
return { success: false, failedIds, error: `${failedIds.length}건의 열람 처리에 실패했습니다.` };
}
return { success: true };
}
/**
* 일괄 미열람 처리
*/
export async function markAsUnreadBulk(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
const failedIds: string[] = [];
for (const id of ids) {
const result = await markAsUnread(id);
if (!result.success) {
failedIds.push(id);
}
if (!result.success) failedIds.push(id);
}
if (failedIds.length > 0) {
return {
success: false,
failedIds,
error: `${failedIds.length}건의 미열람 처리에 실패했습니다.`,
};
return { success: false, failedIds, error: `${failedIds.length}건의 미열람 처리에 실패했습니다.` };
}
return { success: true };
}