"use server" import { getKnoxConfig, createJsonHeaders, createFormHeaders } from '../common'; import { randomUUID } from 'crypto'; import { saveApprovalToDatabase, deleteApprovalFromDatabase } from './service'; import { debugLog, debugError } from '@/lib/debug-utils' // Knox API Approval 서버 액션들 // 가이드: lib/knox-api/approval/guide.html // ========== 타입 정의 ========== // 공통 응답 타입 export interface BaseResponse { result: string; } // 결재 경로 타입 export interface ApprovalLine { epId?: string; userId?: string; // eVCP ID라서 사용하지 않음! emailAddress?: string; seq: string; role: string; // 기안(0), 결재(1), 합의(2), 후결(3), 병렬합의(4), 병렬결재(7), 통보(9) aplnStatsCode: string; // 미결(0), 결재(1), 반려(2), 전결(3), 자동결재(5) arbPmtYn: string; // 전결권한여부 contentsMdfyPmtYn: string; // 본문수정권한여부 aplnMdfyPmtYn: string; // 경로변경권한여부 opinion?: string; // 상신의견 (상신자만) } // 결재 상신 요청 타입 export interface SubmitApprovalRequest { contents: string; // 결재본문 contentsType: string; // 본문종류 (TEXT, HTML, MIME) docSecuType: string; // 보안문서타입 (PERSONAL, CONFIDENTIAL, CONFIDENTIAL_STRICT) notifyOption: string; // 통보옵션 (0-3) urgYn: string; // 긴급여부 (Y/N) sbmDt: string; // 상신일시 (YYYYMMDDHHMMSS) timeZone: string; // 타임존 (GMT, GMT+9 등) docMngSaveCode: string; // 문서관리저장코드 (0: 안함, 1: 저장) subject: string; // 결재제목 sbmLang: string; // 상신언어 (ko, ja, zh, en) apInfId: string; // 연계ID (32자리 고유값) importantYn?: string; // 중요여부 (Y/N) aplns: ApprovalLine[]; // 결재경로 attachments?: File[]; // 첨부파일 } // 결재 상신 응답 타입 export interface SubmitApprovalResponse extends BaseResponse { data: { apInfId: string; }; } // 결재 상세 조회 응답 타입 export interface ApprovalDetailResponse extends BaseResponse { data: { contentsType: string; sbmDt: string; sbmLang: string; apInfId: string; systemId: string; notifyOption: string; urgYn: string; docSecuType: string; status: string; // 암호화실패(-3), 암호화중(-2), 예약상신(-1), 보류(0), 진행중(1), 완결(2), 반려(3), 상신취소(4), 전결(5), 후완결(6) timeZone: string; subject: string; aplns: ApprovalLine[]; attachments?: File[]; }; } // 결재 본문 조회 응답 타입 export interface ApprovalContentResponse extends BaseResponse { data: { contents: string; contentsType: string; apInfId: string; }; } // 결재 상황 조회 요청 타입 export interface ApprovalStatusRequest { apinfids: { apinfid: string }[]; } // 결재 상황 조회 응답 타입 export interface ApprovalStatusResponse extends BaseResponse { data: { apInfId: string; docChgNum: string; status: string; }[]; } // 상신 취소 응답 타입 export interface CancelApprovalResponse extends BaseResponse { data: { apInfId: string; }; } // 개인 결재경로 목록 조회 응답 타입 export interface OwnApprovalLineListResponse extends BaseResponse { data: ApprovalLine[]; } // 개인 결재경로 상세 조회 응답 타입 export interface OwnApprovalLineDetailResponse extends BaseResponse { data: ApprovalLine; } // 상신함 리스트 조회 응답 타입 export interface SubmissionListResponse extends BaseResponse { data: SubmitApprovalRequest[]; } // 연계 이력 조회 응답 타입 export interface ApprovalHistoryResponse extends BaseResponse { data: SubmitApprovalRequest[]; } // 연계 ID 조회 응답 타입 export interface ApprovalIdsResponse extends BaseResponse { data: string[]; } // ========== 서버 액션 함수들 ========== /** * 결재 상신 * POST /approval/api/v2.0/approvals/submit */ export async function submitApproval( request: SubmitApprovalRequest, userInfo: { userId: string; epId: string; emailAddress: string } ): Promise { try { const config = await getKnoxConfig(); const formData = new FormData(); // JSON 데이터 생성 const approvalData = { contents: request.contents, contentsType: request.contentsType, docSecuType: request.docSecuType, notifyOption: request.notifyOption, urgYn: request.urgYn, sbmDt: request.sbmDt, timeZone: request.timeZone, docMngSaveCode: request.docMngSaveCode, subject: request.subject, sbmLang: request.sbmLang || 'ko', apInfId: request.apInfId, // 고정값, 환경변수로 설정해 common 에서 가져오기 importantYn: request.importantYn, aplns: request.aplns }; formData.append('approval', JSON.stringify(approvalData)); // 첨부파일 처리 if (request.attachments) { request.attachments.forEach((file) => { formData.append('attachments', file); }); } const response = await fetch(`${config.baseUrl}/approval/api/v2.0/approvals/submit`, { method: 'POST', headers: await createFormHeaders(), body: formData, }); if (!response.ok) { const errorText = await response.text(); debugError('API Error Response', errorText); throw new Error(`결재 상신 실패: ${response.status} - ${errorText}`); } // 응답의 Content-Type 확인 및 charset 처리 const contentType = response.headers.get('content-type'); let result; if (contentType && contentType.includes('application/json')) { result = await response.json(); } else { // JSON이 아닌 경우 텍스트로 읽고 JSON 파싱 시도 const text = await response.text(); debugLog('Raw response text', text); try { result = JSON.parse(text); } catch (parseError) { console.error('JSON parse error:', parseError); throw new Error(`응답 파싱 실패: ${text}`); } } // Knox API 성공 시 데이터베이스에 저장 if (result.result === 'SUCCESS') { try { await saveApprovalToDatabase( request.apInfId, userInfo.userId, userInfo.epId, userInfo.emailAddress, request.subject, request.contents, request.aplns ); } catch (dbError) { console.error('데이터베이스 저장 실패:', dbError); // 데이터베이스 저장 실패는 Knox API 성공을 무효화하지 않음 // 필요시 별도 처리 로직 추가 } } return result; } catch (error) { debugError('결재 상신 오류', error); throw error; } } /** * 보안 결재 상신 * POST /approval/api/v2.0/approvals/secu-submit */ export async function submitSecurityApproval( request: SubmitApprovalRequest ): Promise { try { const config = await getKnoxConfig(); const formData = new FormData(); // JSON 데이터 생성 const approvalData = { contents: request.contents, contentsType: request.contentsType, docSecuType: request.docSecuType, // CONFIDENTIAL 또는 CONFIDENTIAL_STRICT notifyOption: request.notifyOption, urgYn: request.urgYn, sbmDt: request.sbmDt, timeZone: request.timeZone, docMngSaveCode: request.docMngSaveCode, subject: request.subject, sbmLang: request.sbmLang, apInfId: request.apInfId, importantYn: request.importantYn, aplns: request.aplns }; formData.append('approval', JSON.stringify(approvalData)); // 첨부파일 처리 if (request.attachments) { request.attachments.forEach((file) => { formData.append('attachments', file); }); } const response = await fetch(`${config.baseUrl}/approval/api/v2.0/approvals/secu-submit`, { method: 'POST', headers: await createFormHeaders(), body: formData, }); if (!response.ok) { const errorText = await response.text(); debugError('Security API Error Response', errorText); throw new Error(`보안 결재 상신 실패: ${response.status} - ${errorText}`); } // 응답의 Content-Type 확인 및 charset 처리 const contentType = response.headers.get('content-type'); let result; if (contentType && contentType.includes('application/json')) { result = await response.json(); } else { // JSON이 아닌 경우 텍스트로 읽고 JSON 파싱 시도 const text = await response.text(); debugLog('Raw security response text', text); try { result = JSON.parse(text); } catch (parseError) { console.error('Security JSON parse error:', parseError); throw new Error(`보안 응답 파싱 실패: ${text}`); } } return result; } catch (error) { debugError('보안 결재 상신 오류', error); throw error; } } /** * 결재 상세 상황 조회 * GET /approval/api/v2.0/approvals/{apInfId}/detail */ export async function getApprovalDetail( apInfId: string ): Promise { try { const config = await getKnoxConfig(); const response = await fetch(`${config.baseUrl}/approval/api/v2.0/approvals/${apInfId}/detail`, { method: 'GET', headers: await createJsonHeaders(), }); if (!response.ok) { throw new Error(`결재 상세 조회 실패: ${response.status}`); } return await response.json(); } catch (error) { console.error('결재 상세 조회 오류:', error); throw error; } } /** * 결재 본문 조회 * GET /approval/api/v2.0/approvals/{apInfId}/content */ export async function getApprovalContent( apInfId: string ): Promise { try { const config = await getKnoxConfig(); const response = await fetch(`${config.baseUrl}/approval/api/v2.0/approvals/${apInfId}/content`, { method: 'GET', headers: await createJsonHeaders(), }); if (!response.ok) { throw new Error(`결재 본문 조회 실패: ${response.status}`); } return await response.json(); } catch (error) { console.error('결재 본문 조회 오류:', error); throw error; } } /** * 결재 상황 조회 * POST /approval/api/v2.0/approvals/status */ export async function getApprovalStatus( request: ApprovalStatusRequest ): Promise { try { const config = await getKnoxConfig(); const response = await fetch(`${config.baseUrl}/approval/api/v2.0/approvals/status`, { method: 'POST', headers: await createJsonHeaders(), body: JSON.stringify(request.apinfids), }); if (!response.ok) { throw new Error(`결재 상황 조회 실패: ${response.status}`); } return await response.json(); } catch (error) { console.error('결재 상황 조회 오류:', error); throw error; } } /** * 결재 연계 ID 조회 * GET /approval/api/v2.0/approvals/apinfids */ export async function getApprovalIds( apIds?: string[] ): Promise { try { const config = await getKnoxConfig(); let url = `${config.baseUrl}/approval/api/v2.0/approvals/apinfids`; if (apIds && apIds.length > 0) { const params = new URLSearchParams(); apIds.forEach(id => params.append('apId', id)); url += `?${params.toString()}`; } const response = await fetch(url, { method: 'GET', headers: await createJsonHeaders(), }); if (!response.ok) { throw new Error(`결재 연계 ID 조회 실패: ${response.status}`); } return await response.json(); } catch (error) { console.error('결재 연계 ID 조회 오류:', error); throw error; } } /** * 상신함 리스트 조회 * GET /approval/api/v2.0/approvals/submission */ export async function getSubmissionList( params?: Record ): Promise { try { const config = await getKnoxConfig(); let url = `${config.baseUrl}/approval/api/v2.0/approvals/submission`; if (params) { const searchParams = new URLSearchParams(params); url += `?${searchParams.toString()}`; } const response = await fetch(url, { method: 'GET', headers: await createJsonHeaders(), }); if (!response.ok) { throw new Error(`상신함 리스트 조회 실패: ${response.status}`); } return await response.json(); } catch (error) { console.error('상신함 리스트 조회 오류:', error); throw error; } } /** * 연계 이력 조회 * GET /approval/api/v2.0/approvals/apinfidinfos */ export async function getApprovalHistory( params?: Record ): Promise { try { const config = await getKnoxConfig(); let url = `${config.baseUrl}/approval/api/v2.0/approvals/apinfidinfos`; if (params) { const searchParams = new URLSearchParams(params); url += `?${searchParams.toString()}`; } const response = await fetch(url, { method: 'GET', headers: await createJsonHeaders(), }); if (!response.ok) { throw new Error(`연계 이력 조회 실패: ${response.status}`); } return await response.json(); } catch (error) { console.error('연계 이력 조회 오류:', error); throw error; } } /** * 상신 취소 * POST /approval/api/v2.0/approvals/{apInfId}/cancel */ export async function cancelApproval( apInfId: string ): Promise { try { const config = await getKnoxConfig(); const response = await fetch(`${config.baseUrl}/approval/api/v2.0/approvals/${apInfId}/cancel`, { method: 'POST', headers: await createJsonHeaders(), }); if (!response.ok) { throw new Error(`상신 취소 실패: ${response.status}`); } const result = await response.json(); // Knox API 성공 시 데이터베이스에서 삭제 if (result.result === 'SUCCESS') { try { await deleteApprovalFromDatabase(apInfId); } catch (dbError) { console.error('데이터베이스 삭제 실패:', dbError); // 데이터베이스 삭제 실패는 Knox API 성공을 무효화하지 않음 // 필요시 별도 처리 로직 추가 } } return result; } catch (error) { console.error('상신 취소 오류:', error); throw error; } } /** * 저장된 결재경로 목록 조회 * GET /approval/api/v2.0/approvals/ownaplnlist */ export async function getOwnApprovalLineList( params?: Record ): Promise { try { const config = await getKnoxConfig(); let url = `${config.baseUrl}/approval/api/v2.0/approvals/ownaplnlist`; if (params) { const searchParams = new URLSearchParams(params); url += `?${searchParams.toString()}`; } const response = await fetch(url, { method: 'GET', headers: await createJsonHeaders(), }); if (!response.ok) { throw new Error(`저장된 결재경로 목록 조회 실패: ${response.status}`); } return await response.json(); } catch (error) { console.error('저장된 결재경로 목록 조회 오류:', error); throw error; } } /** * 저장된 결재경로 상세 조회 * GET /approval/api/v2.0/approvals/{pslAplnId}/ownaplndetail */ export async function getOwnApprovalLineDetail( pslAplnId: string ): Promise { try { const config = await getKnoxConfig(); const response = await fetch(`${config.baseUrl}/approval/api/v2.0/approvals/${pslAplnId}/ownaplndetail`, { method: 'GET', headers: await createJsonHeaders(), }); if (!response.ok) { throw new Error(`저장된 결재경로 상세 조회 실패: ${response.status}`); } return await response.json(); } catch (error) { console.error('저장된 결재경로 상세 조회 오류:', error); throw error; } } // ========== 유틸리티 함수들 ========== /** * 결재 상신 요청 데이터 생성 도우미 */ export async function createSubmitApprovalRequest( contents: string, subject: string, approvalLines: ApprovalLine[], options: Partial = {} ): Promise { // 요구하는 날짜 형식으로 변환 (YYYYMMDDHHMMSS) - UTC 기준 (타임존 정보를 GTC+9 로 제공하고 있음) const now = new Date(); const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: 'UTC', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = formatter.formatToParts(now); const sbmDt = parts .filter((part) => part.type !== 'literal') .map((part) => part.value) .join(''); // EVCP 접두어 뒤에 28자리 무작위 문자열을 붙여 32byte 고유 ID 생성 const apInfId = `EVCP${randomUUID().replace(/-/g, '').slice(0, 28)}`; const result = { contents, subject, aplns: approvalLines, contentsType: options.contentsType || 'TEXT', docSecuType: options.docSecuType || 'PERSONAL', notifyOption: options.notifyOption || '0', urgYn: options.urgYn || 'N', sbmDt, timeZone: options.timeZone || 'GMT+9', docMngSaveCode: options.docMngSaveCode || '0', sbmLang: options.sbmLang || 'ko', apInfId, importantYn: options.importantYn || 'N', ...options }; debugLog('Created Submit Request', result); return result; } /** * 결재 라인 생성 도우미 */ export async function createApprovalLine( userInfo: { epId?: string; userId?: string; emailAddress?: string }, role: string, seq: string, options: Partial = {} ): Promise { return { ...userInfo, seq, role, aplnStatsCode: '0', arbPmtYn: 'Y', contentsMdfyPmtYn: 'Y', aplnMdfyPmtYn: 'Y', ...options }; } /** * 결재 상태 문자열 변환 */ export async function getApprovalStatusText(status: string): Promise { const statusMap: Record = { '-3': '암호화실패', '-2': '암호화중', '-1': '예약상신', '0': '보류', '1': '진행중', '2': '완결', '3': '반려', '4': '상신취소', '5': '전결', '6': '후완결' }; return statusMap[status] || '알 수 없음'; } /** * 결재 역할 문자열 변환 */ export async function getApprovalRoleText(role: string): Promise { const roleMap: Record = { '0': '기안', '1': '결재', '2': '합의', '3': '후결', '4': '병렬합의', '7': '병렬결재', '9': '통보' }; return roleMap[role] || '알 수 없음'; }