summaryrefslogtreecommitdiff
path: root/lib/approval/approval-workflow.ts
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-05 19:56:29 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-05 19:56:29 +0900
commit551129656039aae409b3af51ce4acbb59f60229f (patch)
treee041f542cd46920086e84d0071c9e0a76c4b8699 /lib/approval/approval-workflow.ts
parente890fbae0c9c273b825ac808aa516de1f87fb218 (diff)
(김준회) approval: Saga Pattern 클래스로 리팩터링, 명시적으로 개선, 기존 사용 함수 리팩터링
Diffstat (limited to 'lib/approval/approval-workflow.ts')
-rw-r--r--lib/approval/approval-workflow.ts375
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;
- }
-}
-