summaryrefslogtreecommitdiff
path: root/lib/approval/approval-workflow.ts
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-05 10:57:01 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-05 10:57:01 +0900
commit2e1a87c11c4ed65588342bcd66c3acf9a24f90f7 (patch)
treec25aaf9f3ebab94028c4fb8155fe5c036a033624 /lib/approval/approval-workflow.ts
parenta6ad5fcfb65772b9ae240d9fa02bf9ed1a9160c9 (diff)
(김준회) 결재 트랜잭션 개선 (Saga Pattern 도입) 및 문서 작성
Diffstat (limited to 'lib/approval/approval-workflow.ts')
-rw-r--r--lib/approval/approval-workflow.ts188
1 files changed, 118 insertions, 70 deletions
diff --git a/lib/approval/approval-workflow.ts b/lib/approval/approval-workflow.ts
index cc8914f9..ed20e972 100644
--- a/lib/approval/approval-workflow.ts
+++ b/lib/approval/approval-workflow.ts
@@ -82,78 +82,87 @@ export async function withApproval<T>(
throw new Error(`No handler registered for action type: ${actionType}`);
}
- try {
- // 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);
- }
+ // 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
+ // 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(drafterLine);
+ aplns.push(approverLine);
}
-
- // 결재자들
- 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 템플릿 사용
}
-
- // 결재 요청 생성
- const submitRequest = await createSubmitApprovalRequest(
- content, // 치환된 템플릿 content
- approvalConfig.title,
- aplns,
- {
- contentsType: 'HTML', // HTML 템플릿 사용
- }
- );
-
- // Knox 결재 상신
- await submitApproval(
- submitRequest,
- {
- userId: String(approvalConfig.currentUser.id),
- epId: approvalConfig.currentUser.epId || '',
- emailAddress: approvalConfig.currentUser.email || '',
- }
- );
+ );
- // 3. Pending Action 생성 (approvalLog의 apInfId로 연결)
- // Knox에서 apInfId를 반환하지 않고, 요청 시 생성한 apInfId를 사용
+ /**
+ * 트랜잭션 관리 전략 (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 업데이트 실패 위험 제거
+ */
+
+ 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,
@@ -162,15 +171,54 @@ export async function withApproval<T>(
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: pendingAction.id,
+ pendingActionId,
approvalId: submitRequest.apInfId,
- status: 'pending_approval',
+ status: 'pending_approval' as const,
};
} catch (error) {
- console.error('Failed to create approval workflow:', 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;
}
}