diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-05 19:56:29 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-05 19:56:29 +0900 |
| commit | 551129656039aae409b3af51ce4acbb59f60229f (patch) | |
| tree | e041f542cd46920086e84d0071c9e0a76c4b8699 /lib/approval/approval-workflow.ts | |
| parent | e890fbae0c9c273b825ac808aa516de1f87fb218 (diff) | |
(김준회) approval: Saga Pattern 클래스로 리팩터링, 명시적으로 개선, 기존 사용 함수 리팩터링
Diffstat (limited to 'lib/approval/approval-workflow.ts')
| -rw-r--r-- | lib/approval/approval-workflow.ts | 375 |
1 files changed, 5 insertions, 370 deletions
diff --git a/lib/approval/approval-workflow.ts b/lib/approval/approval-workflow.ts index fc6a0dcf..5ae74cbd 100644 --- a/lib/approval/approval-workflow.ts +++ b/lib/approval/approval-workflow.ts @@ -1,22 +1,12 @@ /** - * 결재 워크플로우 관리 모듈 + * 결재 워크플로우 핸들러 레지스트리 * - * 주요 기능: - * 1. 결재가 필요한 액션을 pending 상태로 저장 - * 2. Knox 결재 시스템에 상신 - * 3. 결재 완료 시 저장된 액션 실행 + * Saga 패턴에서 사용할 액션 핸들러들을 등록하고 관리합니다. * * 흐름: - * withApproval() → Knox 상신 → [폴링으로 상태 감지] → executeApprovedAction() + * ApprovalSubmissionSaga → Knox 상신 → [폴링으로 상태 감지] → ApprovalExecutionSaga */ -import db from '@/db/db'; -import { eq } from 'drizzle-orm'; -import { pendingActions } from '@/db/schema/knox/pending-actions'; - -import type { ApprovalConfig } from './types'; -import type { ApprovalLine } from '@/lib/knox-api/approval/approval'; - /** * 액션 핸들러 타입 정의 * payload를 받아서 실제 비즈니스 로직을 수행하는 함수 @@ -28,7 +18,7 @@ export type ActionHandler = (payload: any) => Promise<any>; * 액션 타입별 핸들러 저장소 * registerActionHandler()로 등록된 핸들러들이 여기 저장됨 */ -const actionHandlers = new Map<string, ActionHandler>(); +export const actionHandlers = new Map<string, ActionHandler>(); /** * 특정 액션 타입에 대한 핸들러 등록 @@ -58,7 +48,7 @@ let handlersInitialized = false; * 핸들러가 등록되어 있지 않으면 자동으로 초기화 (Lazy Initialization) * Next.js 서버 액션의 격리된 실행 컨텍스트를 위한 안전장치 */ -async function ensureHandlersInitialized() { +export async function ensureHandlersInitialized() { if (!handlersInitialized) { console.log('[Approval Workflow] Lazy initializing handlers...'); const { initializeApprovalHandlers } = await import('./handlers-registry'); @@ -67,358 +57,3 @@ async function ensureHandlersInitialized() { } } -/** - * 결재가 필요한 액션을 래핑하는 공통 함수 - * - * 사용법: - * ```typescript - * const result = await withApproval( - * 'vendor_investigation_request', - * { vendorId: 123, reason: '실사 필요' }, - * { - * title: '실사 요청 결재', - * description: 'ABC 협력업체 실사 요청', - * templateName: '협력업체 실사 요청', - * variables: { '수신자이름': '결재자', ... }, - * currentUser: { id: 1, epId: 'EP001' } - * } - * ); - * ``` - * - * @param actionType - 액션 타입 (핸들러 등록 시 사용한 키) - * @param actionPayload - 액션 실행에 필요한 데이터 - * @param approvalConfig - 결재 상신 설정 (템플릿명, 변수 포함) - * @returns pendingActionId, approvalId, status - */ -export async function withApproval<T>( - actionType: string, - actionPayload: T, - approvalConfig: ApprovalConfig -) { - // 핸들러 자동 초기화 (서버 액션 격리 문제 해결) - await ensureHandlersInitialized(); - - // 핸들러가 등록되어 있는지 확인 - if (!actionHandlers.has(actionType)) { - console.error('[Approval Workflow] Available handlers:', Array.from(actionHandlers.keys())); - throw new Error(`No handler registered for action type: ${actionType}`); - } - - // 1. 템플릿 조회 및 변수 치환 - const { getApprovalTemplateByName, replaceTemplateVariables } = await import('./template-utils'); - const template = await getApprovalTemplateByName(approvalConfig.templateName); - - let content: string; - if (!template) { - console.warn(`[Approval Workflow] Template not found: ${approvalConfig.templateName}`); - // 템플릿이 없으면 기본 내용 사용 - content = approvalConfig.description || '결재 요청'; - } else { - // 템플릿 변수 치환 - content = await replaceTemplateVariables(template.content, approvalConfig.variables); - } - - // 2. Knox 결재 상신 준비 (apInfId 생성, 치환된 content 사용) - const { - submitApproval, - createSubmitApprovalRequest, - createApprovalLine - } = await import('@/lib/knox-api/approval/approval'); - - // 결재선 생성 - const aplns: ApprovalLine[] = []; - - // 기안자 (현재 사용자) - if (approvalConfig.currentUser.epId) { - const drafterLine = await createApprovalLine( - { - epId: approvalConfig.currentUser.epId, - emailAddress: approvalConfig.currentUser.email, - }, - '0', // 기안 - '0' // seq - ); - aplns.push(drafterLine); - } - - // 결재자들 - if (approvalConfig.approvers && approvalConfig.approvers.length > 0) { - for (let i = 0; i < approvalConfig.approvers.length; i++) { - const approverLine = await createApprovalLine( - { epId: approvalConfig.approvers[i] }, - '1', // 승인 - String(i + 1) - ); - aplns.push(approverLine); - } - } - - // 결재 요청 생성 - const submitRequest = await createSubmitApprovalRequest( - content, // 치환된 템플릿 content - approvalConfig.title, - aplns, - { - contentsType: 'HTML', // HTML 템플릿 사용 - } - ); - - /** - * 트랜잭션 관리 전략 (Saga Pattern): - * - * Knox는 외부 시스템이므로 DB 트랜잭션으로 롤백할 수 없습니다. - * 따라서 다음 순서로 처리하여 데이터 정합성을 보장합니다: - * - * 1. DB에 먼저 pendingAction 생성 (status: 'pending') - * 2. Knox 결재 상신 시도 - * 3-A. Knox 상신 성공 → pendingAction 그대로 유지 - * 3-B. Knox 상신 실패 → pendingAction status를 'failed'로 업데이트 (보상 트랜잭션) - * - * 이렇게 하면: - * - Knox 상신 전에 DB 저장 실패 → 전체 실패 (정상) - * - Knox 상신 실패 → DB에 실패 기록 남음 (추적 가능) - * - Knox 상신 성공 후 DB 업데이트 실패 위험 제거 - */ - - // 캐시 무효화 (결재 상신 시) - console.log(`[Approval Workflow] Revalidating cache after approval submission`); - const { revalidateApprovalLogs } = await import('./cache-utils'); - await revalidateApprovalLogs(); - - let pendingActionId: number | undefined; - - try { - // 3. Pending Action 먼저 생성 (Knox 상신 전) - console.log(`[Approval Workflow] Creating pending action for ${actionType}`); - const [pendingAction] = await db.insert(pendingActions).values({ - apInfId: submitRequest.apInfId, - actionType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - actionPayload: actionPayload as any, - status: 'pending', - createdBy: approvalConfig.currentUser.id, - }).returning(); - - pendingActionId = pendingAction.id; - console.log(`[Approval Workflow] Pending action created: ${pendingActionId}`); - - // 4. Knox 결재 상신 시도 - console.log(`[Approval Workflow] Submitting to Knox: ${submitRequest.apInfId}`); - await submitApproval( - submitRequest, - { - userId: String(approvalConfig.currentUser.id), - epId: approvalConfig.currentUser.epId || '', - emailAddress: approvalConfig.currentUser.email || '', - } - ); - console.log(`[Approval Workflow] ✅ Knox submission successful: ${submitRequest.apInfId}`); - - // 5. 성공 시 정상 반환 - return { - pendingActionId, - approvalId: submitRequest.apInfId, - status: 'pending_approval' as const, - }; - - } catch (error) { - console.error(`[Approval Workflow] ❌ Error during approval workflow:`, error); - - // Knox 상신 실패 시 보상 트랜잭션 (Compensating Transaction) - // pendingAction의 상태를 'failed'로 업데이트 - if (typeof pendingActionId === 'number') { - try { - console.log(`[Approval Workflow] Marking pending action as failed: ${pendingActionId}`); - await db.update(pendingActions) - .set({ - status: 'failed', - errorMessage: error instanceof Error - ? `Knox 상신 실패: ${error.message}` - : 'Knox 상신 실패', - executedAt: new Date(), - }) - .where(eq(pendingActions.id, pendingActionId)); - - console.log(`[Approval Workflow] Pending action marked as failed: ${pendingActionId}`); - } catch (updateError) { - console.error(`[Approval Workflow] Failed to update pending action status:`, updateError); - // 상태 업데이트 실패는 로그만 남기고 원래 에러를 throw - } - } - - throw error; - } -} - -/** - * 결재가 승인된 액션을 실행 - * 폴링 서비스에서 호출됨 - * - * @param apInfId - Knox 결재 ID (approvalLogs의 primary key) - * @returns 액션 실행 결과 - */ -export async function executeApprovedAction(apInfId: string) { - // debug-utils import - const { debugLog, debugError, debugSuccess } = await import('@/lib/debug-utils'); - - debugLog('[executeApprovedAction] 시작', { apInfId }); - - try { - // 핸들러 자동 초기화 (폴링 서비스의 격리 문제 해결) - debugLog('[executeApprovedAction] 핸들러 초기화 중'); - await ensureHandlersInitialized(); - debugLog('[executeApprovedAction] 핸들러 초기화 완료'); - - // 1. apInfId로 pendingAction 조회 - debugLog('[executeApprovedAction] pendingAction 조회 중', { apInfId }); - const pendingAction = await db.query.pendingActions.findFirst({ - where: eq(pendingActions.apInfId, apInfId), - }); - - if (!pendingAction) { - debugLog('[executeApprovedAction] pendingAction 없음 (결재만 존재)', { apInfId }); - console.log(`[Approval Workflow] No pending action found for approval: ${apInfId}`); - return null; // 결재만 있고 실행할 액션이 없는 경우 - } - - debugLog('[executeApprovedAction] pendingAction 조회 완료', { - id: pendingAction.id, - actionType: pendingAction.actionType, - status: pendingAction.status, - createdBy: pendingAction.createdBy, - }); - - // 이미 실행되었거나 실패한 액션은 스킵 - if (['executed', 'failed'].includes(pendingAction.status)) { - debugLog('[executeApprovedAction] 이미 처리된 액션 스킵', { - apInfId, - status: pendingAction.status, - }); - console.log(`[Approval Workflow] Pending action already processed: ${apInfId} (${pendingAction.status})`); - return null; - } - - // 2. 등록된 핸들러 조회 - debugLog('[executeApprovedAction] 핸들러 조회 중', { - actionType: pendingAction.actionType, - }); - const handler = actionHandlers.get(pendingAction.actionType); - if (!handler) { - debugError('[executeApprovedAction] 핸들러를 찾을 수 없음', { - actionType: pendingAction.actionType, - availableHandlers: Array.from(actionHandlers.keys()), - }); - console.error('[Approval Workflow] Available handlers:', Array.from(actionHandlers.keys())); - throw new Error(`Handler not found for action type: ${pendingAction.actionType}`); - } - debugLog('[executeApprovedAction] 핸들러 조회 완료', { - actionType: pendingAction.actionType, - }); - - // 3. 실제 액션 실행 - debugLog('[executeApprovedAction] 핸들러 실행 시작', { - actionType: pendingAction.actionType, - apInfId, - payloadKeys: Object.keys(pendingAction.actionPayload || {}), - }); - console.log(`[Approval Workflow] Executing action: ${pendingAction.actionType} (${apInfId})`); - - const result = await handler(pendingAction.actionPayload); - - debugSuccess('[executeApprovedAction] 핸들러 실행 완료', { - actionType: pendingAction.actionType, - apInfId, - resultKeys: result ? Object.keys(result) : [], - }); - - // 4. 실행 완료 상태 업데이트 - debugLog('[executeApprovedAction] 상태 업데이트 중 (executed)'); - await db.update(pendingActions) - .set({ - status: 'executed', - executedAt: new Date(), - executionResult: result, - }) - .where(eq(pendingActions.apInfId, apInfId)); - debugLog('[executeApprovedAction] 상태 업데이트 완료 (executed)'); - - // 5. 캐시 무효화 (백그라운드에서도 동작) - debugLog('[executeApprovedAction] 캐시 무효화 중'); - const { revalidateApprovalLogs } = await import('./cache-utils'); - await revalidateApprovalLogs(); - debugLog('[executeApprovedAction] 캐시 무효화 완료'); - - debugSuccess('[executeApprovedAction] 전체 프로세스 완료', { - actionType: pendingAction.actionType, - apInfId, - }); - console.log(`[Approval Workflow] ✅ Successfully executed: ${pendingAction.actionType} (${apInfId})`); - return result; - - } catch (error) { - debugError('[executeApprovedAction] 실행 중 에러 발생', { - apInfId, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); - console.error(`[Approval Workflow] ❌ Failed to execute action for ${apInfId}:`, error); - - // 실패 상태 업데이트 - try { - debugLog('[executeApprovedAction] 상태 업데이트 중 (failed)'); - await db.update(pendingActions) - .set({ - status: 'failed', - errorMessage: error instanceof Error ? error.message : String(error), - executedAt: new Date(), - }) - .where(eq(pendingActions.apInfId, apInfId)); - debugLog('[executeApprovedAction] 상태 업데이트 완료 (failed)'); - } catch (updateError) { - debugError('[executeApprovedAction] 상태 업데이트 실패', { - apInfId, - updateError: updateError instanceof Error ? updateError.message : String(updateError), - }); - } - - throw error; - } -} - -/** - * 결재 반려 시 처리 - * - * @param apInfId - Knox 결재 ID (approvalLogs의 primary key) - * @param reason - 반려 사유 - */ -export async function handleRejectedAction(apInfId: string, reason?: string) { - try { - const pendingAction = await db.query.pendingActions.findFirst({ - where: eq(pendingActions.apInfId, apInfId), - }); - - if (!pendingAction) { - console.log(`[Approval Workflow] No pending action found for rejected approval: ${apInfId}`); - return; // 결재만 있고 실행할 액션이 없는 경우 - } - - await db.update(pendingActions) - .set({ - status: 'rejected', - errorMessage: reason, - executedAt: new Date(), - }) - .where(eq(pendingActions.apInfId, apInfId)); - - // 캐시 무효화 (백그라운드에서도 동작) - console.log(`[Approval Workflow] Revalidating cache for rejected action: ${apInfId}`); - const { revalidateApprovalLogs } = await import('./cache-utils'); - await revalidateApprovalLogs(); - - // TODO: 요청자에게 알림 발송 등 추가 처리 - } catch (error) { - console.error(`[Approval Workflow] Failed to handle rejected action for approval ${apInfId}:`, error); - throw error; - } -} - |
