summaryrefslogtreecommitdiff
path: root/lib/approval/approval-saga.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-saga.ts
parente890fbae0c9c273b825ac808aa516de1f87fb218 (diff)
(김준회) approval: Saga Pattern 클래스로 리팩터링, 명시적으로 개선, 기존 사용 함수 리팩터링
Diffstat (limited to 'lib/approval/approval-saga.ts')
-rw-r--r--lib/approval/approval-saga.ts623
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`);
+ }
+}
+