diff options
Diffstat (limited to 'lib/approval/approval-saga.ts')
| -rw-r--r-- | lib/approval/approval-saga.ts | 623 |
1 files changed, 623 insertions, 0 deletions
diff --git a/lib/approval/approval-saga.ts b/lib/approval/approval-saga.ts new file mode 100644 index 00000000..0bbf7836 --- /dev/null +++ b/lib/approval/approval-saga.ts @@ -0,0 +1,623 @@ +/** + * 결재 워크플로우 Saga Orchestrator + * + * Saga 패턴을 통해 결재 프로세스의 각 단계를 명시적으로 관리하고, + * 실패 시 보상 트랜잭션(Compensating Transaction)을 자동으로 실행합니다. + * + * 비즈니스 흐름: + * 1. 핸들러 초기화 + * 2. 템플릿 준비 + * 3. 결재선 생성 + * 4. 결재 요청 생성 + * 5. DB에 Pending Action 저장 + * 6. Knox 시스템에 결재 상신 + * 7. 캐시 무효화 + * + * 실패 시 보상 트랜잭션: + * - Knox 상신 실패 → Pending Action 상태를 'failed'로 업데이트 + */ + +import db from '@/db/db'; +import { eq } from 'drizzle-orm'; +import { pendingActions } from '@/db/schema/knox/pending-actions'; + +import type { ApprovalConfig, ApprovalResult } from './types'; +import type { ApprovalLine, SubmitApprovalRequest } from '@/lib/knox-api/approval/approval'; +import type { ActionHandler } from './approval-workflow'; + +/** + * Pending Action 타입 정의 + */ +interface PendingActionRecord { + id: number; + apInfId: string; + actionType: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + actionPayload: any; + status: string; + createdBy: number; + executedAt: Date | null; + errorMessage: string | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + executionResult: any; +} + +/** + * 결재 상신 Saga Orchestrator + * + * @example + * const saga = new ApprovalSubmissionSaga( + * 'vendor_investigation_request', + * { vendorId: 123 }, + * { title: '실사 요청', templateName: '협력업체 실사 요청', ... } + * ); + * const result = await saga.execute(); + */ +export class ApprovalSubmissionSaga<T> { + private actionType: string; + private actionPayload: T; + private approvalConfig: ApprovalConfig; + + // Saga 상태 추적 + private pendingActionId?: number; + private submitRequest?: SubmitApprovalRequest; + private templateContent?: string; + + constructor( + actionType: string, + actionPayload: T, + approvalConfig: ApprovalConfig + ) { + this.actionType = actionType; + this.actionPayload = actionPayload; + this.approvalConfig = approvalConfig; + } + + /** + * Saga 실행 - 모든 단계를 순차적으로 실행 + */ + async execute(): Promise<ApprovalResult> { + try { + console.log(`[ApprovalSaga] Starting approval saga for ${this.actionType}`); + + // 1단계: 핸들러 초기화 + await this.initializeHandlers(); + + // 2단계: 템플릿 준비 (변수 치환) + await this.prepareApprovalTemplate(); + + // 3단계: 결재선 생성 + const approvalLines = await this.createApprovalLines(); + + // 4단계: Knox 결재 요청 생성 + await this.createSubmitRequest(approvalLines); + + // 5단계: DB에 Pending Action 저장 (Knox 상신 전) + await this.savePendingAction(); + + // 6단계: Knox 시스템에 결재 상신 + await this.submitToKnox(); + + // 7단계: 캐시 무효화 + await this.invalidateCache(); + + console.log(`[ApprovalSaga] ✅ Saga completed successfully for ${this.actionType}`); + + return { + pendingActionId: this.pendingActionId!, + approvalId: this.submitRequest!.apInfId, + status: 'pending_approval' as const, + }; + + } catch (error) { + console.error(`[ApprovalSaga] ❌ Saga failed for ${this.actionType}:`, error); + + // 보상 트랜잭션 실행 + await this.compensate(error); + + throw error; + } + } + + /** + * 1단계: 핸들러 초기화 + * Next.js 서버 액션의 격리된 실행 컨텍스트를 위한 Lazy Initialization + */ + private async initializeHandlers(): Promise<void> { + console.log(`[ApprovalSaga] Step 1: Initializing handlers`); + + const { ensureHandlersInitialized, actionHandlers } = await import('./approval-workflow'); + await ensureHandlersInitialized(); + + // 핸들러 존재 여부 확인 + if (!actionHandlers.has(this.actionType)) { + const availableHandlers = Array.from(actionHandlers.keys()); + console.error('[ApprovalSaga] Available handlers:', availableHandlers); + throw new Error(`No handler registered for action type: ${this.actionType}`); + } + + console.log(`[ApprovalSaga] ✓ Handlers initialized`); + } + + /** + * 2단계: 템플릿 준비 (변수 치환) + */ + private async prepareApprovalTemplate(): Promise<void> { + console.log(`[ApprovalSaga] Step 2: Preparing approval template`); + + const { getApprovalTemplateByName, replaceTemplateVariables } = + await import('./template-utils'); + + const template = await getApprovalTemplateByName(this.approvalConfig.templateName); + + if (!template) { + console.warn(`[ApprovalSaga] Template not found: ${this.approvalConfig.templateName}`); + this.templateContent = this.approvalConfig.description || '결재 요청'; + } else { + this.templateContent = await replaceTemplateVariables( + template.content, + this.approvalConfig.variables + ); + } + + console.log(`[ApprovalSaga] ✓ Template prepared`); + } + + /** + * 3단계: 결재선 생성 + */ + private async createApprovalLines(): Promise<ApprovalLine[]> { + console.log(`[ApprovalSaga] Step 3: Creating approval lines`); + + const { createApprovalLine } = await import('@/lib/knox-api/approval/approval'); + const aplns: ApprovalLine[] = []; + + // 기안자 (현재 사용자) + if (this.approvalConfig.currentUser.epId) { + const drafterLine = await createApprovalLine( + { + epId: this.approvalConfig.currentUser.epId, + emailAddress: this.approvalConfig.currentUser.email, + }, + '0', // 기안 + '0' // seq + ); + aplns.push(drafterLine); + } + + // 결재자들 + if (this.approvalConfig.approvers && this.approvalConfig.approvers.length > 0) { + for (let i = 0; i < this.approvalConfig.approvers.length; i++) { + const approverLine = await createApprovalLine( + { epId: this.approvalConfig.approvers[i] }, + '1', // 승인 + String(i + 1) + ); + aplns.push(approverLine); + } + } + + console.log(`[ApprovalSaga] ✓ Created ${aplns.length} approval lines`); + return aplns; + } + + /** + * 4단계: Knox 결재 요청 생성 + */ + private async createSubmitRequest(approvalLines: ApprovalLine[]): Promise<void> { + console.log(`[ApprovalSaga] Step 4: Creating submit request`); + + const { createSubmitApprovalRequest } = await import('@/lib/knox-api/approval/approval'); + + this.submitRequest = await createSubmitApprovalRequest( + this.templateContent!, // 치환된 템플릿 content + this.approvalConfig.title, + approvalLines, + { + contentsType: 'HTML', // HTML 템플릿 사용 + } + ); + + console.log(`[ApprovalSaga] ✓ Submit request created (apInfId: ${this.submitRequest.apInfId})`); + } + + /** + * 5단계: DB에 Pending Action 저장 + * + * Saga 패턴의 핵심: Knox 상신 전에 DB에 먼저 저장 + * 이렇게 하면 Knox 상신 실패 시 보상 트랜잭션 가능 + */ + private async savePendingAction(): Promise<void> { + console.log(`[ApprovalSaga] Step 5: Saving pending action to DB`); + + const [pendingAction] = await db.insert(pendingActions).values({ + apInfId: this.submitRequest!.apInfId, + actionType: this.actionType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + actionPayload: this.actionPayload as any, + status: 'pending', + createdBy: this.approvalConfig.currentUser.id, + }).returning(); + + this.pendingActionId = pendingAction.id; + console.log(`[ApprovalSaga] ✓ Pending action saved (ID: ${this.pendingActionId})`); + } + + /** + * 6단계: Knox 시스템에 결재 상신 + * + * 외부 시스템 호출 - 실패 시 보상 트랜잭션 필요 + */ + private async submitToKnox(): Promise<void> { + console.log(`[ApprovalSaga] Step 6: Submitting to Knox system`); + + const { submitApproval } = await import('@/lib/knox-api/approval/approval'); + + await submitApproval( + this.submitRequest!, + { + userId: String(this.approvalConfig.currentUser.id), + epId: this.approvalConfig.currentUser.epId || '', + emailAddress: this.approvalConfig.currentUser.email || '', + } + ); + + console.log(`[ApprovalSaga] ✓ Successfully submitted to Knox`); + } + + /** + * 7단계: 캐시 무효화 + */ + private async invalidateCache(): Promise<void> { + console.log(`[ApprovalSaga] Step 7: Invalidating cache`); + + const { revalidateApprovalLogs } = await import('./cache-utils'); + await revalidateApprovalLogs(); + + console.log(`[ApprovalSaga] ✓ Cache invalidated`); + } + + /** + * 보상 트랜잭션 (Compensating Transaction) + * + * Knox 상신 실패 시 DB의 pending action 상태를 'failed'로 업데이트 + * Saga 패턴의 핵심: 분산 트랜잭션의 일관성 보장 + */ + private async compensate(error: unknown): Promise<void> { + console.log(`[ApprovalSaga] Executing compensating transaction`); + + if (typeof this.pendingActionId !== 'number') { + console.log(`[ApprovalSaga] No pending action to compensate`); + return; + } + + try { + console.log(`[ApprovalSaga] Marking pending action as failed: ${this.pendingActionId}`); + + await db.update(pendingActions) + .set({ + status: 'failed', + errorMessage: error instanceof Error + ? `Knox 상신 실패: ${error.message}` + : 'Knox 상신 실패', + executedAt: new Date(), + }) + .where(eq(pendingActions.id, this.pendingActionId)); + + console.log(`[ApprovalSaga] ✓ Compensating transaction completed`); + + } catch (compensateError) { + console.error(`[ApprovalSaga] ❌ Compensating transaction failed:`, compensateError); + // 보상 트랜잭션 실패는 로그만 남기고 원래 에러를 throw + } + } +} + +/** + * 결재 승인 후 액션 실행 Saga Orchestrator + * + * @example + * const saga = new ApprovalExecutionSaga('AP-2024-001'); + * await saga.execute(); + */ +export class ApprovalExecutionSaga { + private apInfId: string; + + // Saga 상태 추적 + private pendingAction?: PendingActionRecord; + private handler?: ActionHandler; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private executionResult?: any; + + constructor(apInfId: string) { + this.apInfId = apInfId; + } + + /** + * Saga 실행 - 승인된 액션 실행 + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async execute(): Promise<any> { + const { debugLog, debugError, debugSuccess } = await import('@/lib/debug-utils'); + + try { + debugLog('[ExecutionSaga] Starting execution saga', { apInfId: this.apInfId }); + + // 1단계: 핸들러 초기화 + await this.initializeHandlers(); + + // 2단계: Pending Action 조회 + await this.loadPendingAction(); + + // 3단계: 실행 가능 여부 확인 + this.validateExecutionState(); + + // 4단계: 액션 핸들러 조회 + await this.loadActionHandler(); + + // 5단계: 액션 실행 + await this.executeAction(); + + // 6단계: 실행 완료 상태 업데이트 + await this.markAsExecuted(); + + // 7단계: 캐시 무효화 + await this.invalidateCache(); + + debugSuccess('[ExecutionSaga] ✅ Execution saga completed', { + apInfId: this.apInfId, + actionType: this.pendingAction?.actionType, + }); + + return this.executionResult; + + } catch (error) { + debugError('[ExecutionSaga] ❌ Execution saga failed', { + apInfId: this.apInfId, + error: error instanceof Error ? error.message : String(error), + }); + + // 보상 트랜잭션 실행 + await this.compensate(error); + + throw error; + } + } + + /** + * 1단계: 핸들러 초기화 + */ + private async initializeHandlers(): Promise<void> { + const { debugLog } = await import('@/lib/debug-utils'); + debugLog('[ExecutionSaga] Step 1: Initializing handlers'); + + const { ensureHandlersInitialized } = await import('./approval-workflow'); + await ensureHandlersInitialized(); + + debugLog('[ExecutionSaga] ✓ Handlers initialized'); + } + + /** + * 2단계: Pending Action 조회 + */ + private async loadPendingAction(): Promise<void> { + const { debugLog } = await import('@/lib/debug-utils'); + debugLog('[ExecutionSaga] Step 2: Loading pending action', { apInfId: this.apInfId }); + + this.pendingAction = await db.query.pendingActions.findFirst({ + where: eq(pendingActions.apInfId, this.apInfId), + }); + + if (!this.pendingAction) { + debugLog('[ExecutionSaga] No pending action found', { apInfId: this.apInfId }); + console.log(`[ExecutionSaga] No pending action found for approval: ${this.apInfId}`); + return; // 결재만 있고 실행할 액션이 없는 경우 + } + + debugLog('[ExecutionSaga] ✓ Pending action loaded', { + id: this.pendingAction.id, + actionType: this.pendingAction.actionType, + status: this.pendingAction.status, + }); + } + + /** + * 3단계: 실행 가능 여부 확인 + */ + private async validateExecutionState(): Promise<void> { + if (!this.pendingAction) { + return; // 이미 2단계에서 처리됨 + } + + const { debugLog } = await import('@/lib/debug-utils'); + + // 이미 실행되었거나 실패한 액션은 스킵 + if (['executed', 'failed'].includes(this.pendingAction.status)) { + debugLog('[ExecutionSaga] Action already processed', { + apInfId: this.apInfId, + status: this.pendingAction.status, + }); + console.log(`[ExecutionSaga] Pending action already processed: ${this.apInfId}`); + throw new Error('Action already processed'); + } + + debugLog('[ExecutionSaga] ✓ Action is ready for execution'); + } + + /** + * 4단계: 액션 핸들러 조회 + */ + private async loadActionHandler(): Promise<void> { + if (!this.pendingAction) return; + + const { debugLog, debugError } = await import('@/lib/debug-utils'); + debugLog('[ExecutionSaga] Step 4: Loading action handler', { + actionType: this.pendingAction.actionType, + }); + + const { actionHandlers } = await import('./approval-workflow'); + this.handler = actionHandlers.get(this.pendingAction.actionType); + + if (!this.handler) { + const availableHandlers = Array.from(actionHandlers.keys()); + debugError('[ExecutionSaga] Handler not found', { + actionType: this.pendingAction.actionType, + availableHandlers, + }); + throw new Error(`Handler not found for action type: ${this.pendingAction.actionType}`); + } + + debugLog('[ExecutionSaga] ✓ Handler loaded'); + } + + /** + * 5단계: 액션 실행 + */ + private async executeAction(): Promise<void> { + if (!this.pendingAction || !this.handler) return; + + const { debugLog, debugSuccess } = await import('@/lib/debug-utils'); + debugLog('[ExecutionSaga] Step 5: Executing action', { + actionType: this.pendingAction.actionType, + apInfId: this.apInfId, + }); + + console.log(`[ExecutionSaga] Executing action: ${this.pendingAction.actionType} (${this.apInfId})`); + + this.executionResult = await this.handler(this.pendingAction.actionPayload); + + debugSuccess('[ExecutionSaga] ✓ Action executed', { + actionType: this.pendingAction.actionType, + resultKeys: this.executionResult ? Object.keys(this.executionResult) : [], + }); + } + + /** + * 6단계: 실행 완료 상태 업데이트 + */ + private async markAsExecuted(): Promise<void> { + if (!this.pendingAction) return; + + const { debugLog } = await import('@/lib/debug-utils'); + debugLog('[ExecutionSaga] Step 6: Marking as executed'); + + await db.update(pendingActions) + .set({ + status: 'executed', + executedAt: new Date(), + executionResult: this.executionResult, + }) + .where(eq(pendingActions.apInfId, this.apInfId)); + + debugLog('[ExecutionSaga] ✓ Marked as executed'); + } + + /** + * 7단계: 캐시 무효화 + */ + private async invalidateCache(): Promise<void> { + const { debugLog } = await import('@/lib/debug-utils'); + debugLog('[ExecutionSaga] Step 7: Invalidating cache'); + + const { revalidateApprovalLogs } = await import('./cache-utils'); + await revalidateApprovalLogs(); + + debugLog('[ExecutionSaga] ✓ Cache invalidated'); + } + + /** + * 보상 트랜잭션 - 실행 실패 시 상태 업데이트 + */ + private async compensate(error: unknown): Promise<void> { + if (!this.pendingAction) return; + + const { debugLog, debugError } = await import('@/lib/debug-utils'); + debugLog('[ExecutionSaga] Executing compensating transaction'); + + try { + await db.update(pendingActions) + .set({ + status: 'failed', + errorMessage: error instanceof Error ? error.message : String(error), + executedAt: new Date(), + }) + .where(eq(pendingActions.apInfId, this.apInfId)); + + debugLog('[ExecutionSaga] ✓ Compensating transaction completed'); + + } catch (compensateError) { + debugError('[ExecutionSaga] ❌ Compensating transaction failed', { + error: compensateError instanceof Error ? compensateError.message : String(compensateError), + }); + } + } +} + +/** + * 결재 반려 처리 Saga Orchestrator + */ +export class ApprovalRejectionSaga { + private apInfId: string; + private reason?: string; + + constructor(apInfId: string, reason?: string) { + this.apInfId = apInfId; + this.reason = reason; + } + + async execute(): Promise<void> { + try { + console.log(`[RejectionSaga] Starting rejection saga for ${this.apInfId}`); + + // 1단계: Pending Action 조회 + const pendingAction = await this.loadPendingAction(); + + if (!pendingAction) { + console.log(`[RejectionSaga] No pending action found for ${this.apInfId}`); + return; + } + + // 2단계: 반려 상태 업데이트 + await this.markAsRejected(); + + // 3단계: 캐시 무효화 + await this.invalidateCache(); + + // 4단계: 알림 발송 (TODO) + await this.sendNotification(); + + console.log(`[RejectionSaga] ✅ Rejection saga completed`); + + } catch (error) { + console.error(`[RejectionSaga] ❌ Rejection saga failed:`, error); + throw error; + } + } + + private async loadPendingAction() { + return await db.query.pendingActions.findFirst({ + where: eq(pendingActions.apInfId, this.apInfId), + }); + } + + private async markAsRejected(): Promise<void> { + await db.update(pendingActions) + .set({ + status: 'rejected', + errorMessage: this.reason, + executedAt: new Date(), + }) + .where(eq(pendingActions.apInfId, this.apInfId)); + } + + private async invalidateCache(): Promise<void> { + const { revalidateApprovalLogs } = await import('./cache-utils'); + await revalidateApprovalLogs(); + } + + private async sendNotification(): Promise<void> { + // TODO: 요청자에게 알림 발송 + console.log(`[RejectionSaga] TODO: Send notification for rejection`); + } +} + |
