From 1a2241c40e10193c5ff7008a7b7b36cc1d855d96 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Tue, 25 Mar 2025 15:55:45 +0900 Subject: initial commit --- lib/docuSign/docuSignFns.ts | 383 ++++++++++++++++++++++++++++++++++ lib/docuSign/jwtConfig/README.md | 54 +++++ lib/docuSign/jwtConfig/jwtConfig.json | 6 + lib/docuSign/jwtConfig/private.key | 29 +++ lib/docuSign/types.ts | 37 ++++ 5 files changed, 509 insertions(+) create mode 100644 lib/docuSign/docuSignFns.ts create mode 100644 lib/docuSign/jwtConfig/README.md create mode 100644 lib/docuSign/jwtConfig/jwtConfig.json create mode 100644 lib/docuSign/jwtConfig/private.key create mode 100644 lib/docuSign/types.ts (limited to 'lib/docuSign') diff --git a/lib/docuSign/docuSignFns.ts b/lib/docuSign/docuSignFns.ts new file mode 100644 index 00000000..87977a0b --- /dev/null +++ b/lib/docuSign/docuSignFns.ts @@ -0,0 +1,383 @@ +"use server"; + +import docusign from "docusign-esign"; +import fs from "fs"; +import path from "path"; +import jwtConfig from "./jwtConfig/jwtConfig.json"; +import dayjs from "dayjs"; +import { ContractInfo, ContractorInfo } from "./types"; + +const SCOPES = ["signature", "impersonation"]; + +//DocuSign 인증 정보 +async function authenticate(): Promise< + | undefined + | { + accessToken: string; + apiAccountId: string; + basePath: string; + } +> { + const jwtLifeSec = 10 * 60; + const dsApi = new docusign.ApiClient(); + dsApi.setOAuthBasePath(jwtConfig.dsOauthServer.replace("https://", "")); + const privateKeyPath = path.resolve( + process.cwd(), + jwtConfig.privateKeyLocation + ); + + let rsaKey: Buffer = fs.readFileSync(privateKeyPath); + + try { + const results = await dsApi.requestJWTUserToken( + jwtConfig.dsJWTClientId, + jwtConfig.impersonatedUserGuid, + SCOPES, + rsaKey, + jwtLifeSec + ); + const accessToken = results.body.access_token; + + const userInfoResults = await dsApi.getUserInfo(accessToken); + let userInfo = userInfoResults.accounts.find( + (account: Partial<{ isDefault: string }>) => account.isDefault === "true" + ); + + return { + accessToken: results.body.access_token, + apiAccountId: userInfo.accountId, + basePath: `${userInfo.baseUri}/restapi`, + }; + } catch (e) { + console.error("❌ 인증 실패:", e); + } +} + +async function getSignerId( + basePath: string, + accountId: string, + accessToken: string, + envelopeId: string, + roleName: string +): Promise { + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + try { + const recipients = await envelopesApi.listRecipients(accountId, envelopeId); + + const singers = recipients?.signers ?? []; + + // 🔹 특정 서명자(Role Name 기준)의 Recipient ID 찾기 + const signer = singers.find((s) => s.roleName === roleName); + if (!signer) { + console.error("❌ 해당 Role Name을 가진 서명자를 찾을 수 없습니다."); + return null; + } + + return signer.recipientId as string; + } catch (error) { + console.error("❌ 서명자 ID 조회 실패:", error); + return null; + } +} + +//계약서 서명 요청 +export async function requestContractSign( + contractTemplateId: string, + contractInfo: ContractInfo[], + subcontractorinfo: ContractorInfo, + contractorInfo: ContractorInfo, + ccInfo: ContractorInfo[], + brandId: string | undefined = undefined +): Promise< + Partial<{ + result: boolean; + envelopeId: string; + error: any; + }> +> { + let accountInfo = await authenticate(); + if (accountInfo) { + const { accessToken, basePath, apiAccountId } = accountInfo; + const { + email: subEmail, + name: subConName, + roleName: subRoleName, + } = subcontractorinfo; + + const { + email: conEmail, + name: conName, + roleName: roleName, + } = contractorInfo; + + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + const signer1: docusign.TemplateRole = { + email: subEmail, + name: subConName, + roleName: subRoleName, + }; + + const signer1Tabs: docusign.Tabs = { + textTabs: [ + ...contractInfo.map((c): docusign.Text => { + const textField: docusign.Text = { + tabLabel: c.tabLabel, + value: c.value, + locked: "true", + }; + return textField; + }), + ], + }; + + const signer2: docusign.TemplateRole = { + email: conEmail, + name: conName, + roleName: roleName, + }; + + const signer2Tabs: docusign.Tabs = { + dateSignedTabs: [ + { + tabLabel: "contract_complete_date", + }, + ], + }; + + signer1.tabs = signer1Tabs; + signer2.tabs = signer2Tabs; + + const envelopeDefinition: docusign.EnvelopeDefinition = { + templateId: contractTemplateId, + templateRoles: [signer1, signer2, ...ccInfo], // 두 명의 서명자 추가 + status: "sent", // 즉시 발송 + }; + + if (brandId) { + envelopeDefinition.brandId = brandId; + } + + try { + let envelopeSummary = await envelopesApi.createEnvelope(apiAccountId, { + envelopeDefinition, + }); + + // console.log("✅ 서명 요청 완료, Envelope ID:", envelopeSummary); + return { + result: true, + envelopeId: envelopeSummary.envelopeId, + }; + } catch (error) { + console.dir(error); + return { + result: false, + error, + }; + } + } else { + return { + result: false, + }; + } +} + +//서명된 계약서 다운로드 +export async function downloadContractFile(envelopeId: string): Promise< + Partial<{ + result: boolean; + fileName: string; + buffer: Buffer; + envelopeId: string; + error: any; + }> +> { + let accountInfo = await authenticate(); + + if (accountInfo) { + const { accessToken, apiAccountId, basePath } = accountInfo; + + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + try { + //Document ID 등 파일 정보를 호출 + const response = await envelopesApi.listDocuments( + apiAccountId, + envelopeId, + null + ); + + const { envelopeDocuments } = response || { envelopeDocuments: [] }; + + if (Array.isArray(envelopeDocuments) && envelopeDocuments.length > 0) { + const { documentId, name } = envelopeDocuments[0] as { + documentId: string; + name: string; + }; + + //Document Buffer 호출 + const downloadFile = await envelopesApi.getDocument( + apiAccountId, + envelopeId, + documentId, + {} + ); + + if (documentId && documentId !== "certificate") { + const bufferData: Buffer = downloadFile as unknown as Buffer; + return { + result: true, + fileName: name, + buffer: bufferData, + envelopeId, + }; + } + } + + return { + result: false, + }; + } catch (error) { + return { + result: false, + error, + }; + } + } else { + return { + result: false, + }; + } +} + +//최종 서명 날짜 찾기 +export async function findContractCompleteTime( + envelopeId: string, + lastSignerRoleName: string +): Promise<{ + completedDateTime: string; + year: string; + month: string; + day: string; + time: string; +} | null> { + let accountInfo = await authenticate(); + + if (!accountInfo) { + console.error("❌ 인증 실패: API 요청을 중단합니다."); + return null; + } + + const { accessToken, apiAccountId: accountId, basePath } = accountInfo; + + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + try { + const envelope = await envelopesApi.getEnvelope(accountId, envelopeId); + if (!envelope.completedDateTime) { + console.error("❌ 서명 완료 날짜가 없습니다."); + return null; + } + + // 🔹 `SIGNER_ID` 가져오기 + const signerId = await getSignerId( + basePath, + accountId, + accessToken, + envelopeId, + lastSignerRoleName + ); + if (!signerId) { + console.error("❌ 서명자 ID를 찾을 수 없습니다."); + return null; + } + + const completedDate = dayjs(envelope.completedDateTime); + const year = completedDate.format("YYYY").toString(); + const month = completedDate.format("MM").toString(); + const day = completedDate.format("DD").toString(); + const time = completedDate.format("HH:mm").toString(); + + return { + completedDateTime: envelope.completedDateTime, + year, + month, + day, + time, + }; + } catch (error) { + console.error("❌ 서명 완료 후 날짜 추가 실패:", error); + return null; + } +} + +export async function getRecipients( + envelopeId: string, + recipientId: string +): Promise<{ result: boolean; message?: string }> { + try { + let accountInfo = await authenticate(); + + if (!accountInfo) { + console.error("❌ 인증 실패: API 요청을 중단합니다."); + return { + result: false, + message: "인증 실패: API 요청을 중단합니다.", + }; + } + + const { accessToken, apiAccountId: accountId, basePath } = accountInfo; + + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + const response = await envelopesApi.listRecipients(accountId, envelopeId); + + const singers: { [key: string]: any }[] = response?.signers ?? []; + + // 🔹 특정 서명자(Role Name 기준)의 Recipient ID 찾기 + const signer = singers.find((s) => s.recipientId === recipientId); + if (!signer) { + console.error("❌ 해당 Role Name을 가진 서명자를 찾을 수 없습니다."); + return { + result: false, + message: "해당 Recipient id를 가진 서명자를 찾을 수 없습니다.", + }; + } + + const { autoRespondedReason, status } = signer; + + if (autoRespondedReason || status === "status") { + return { + result: false, + message: autoRespondedReason, + }; + } + + return { + result: true, + }; + } catch (error) { + console.error("Error retrieving recipients:", error); + return { result: false, message: (error as Error).message }; + } +} diff --git a/lib/docuSign/jwtConfig/README.md b/lib/docuSign/jwtConfig/README.md new file mode 100644 index 00000000..7c997d07 --- /dev/null +++ b/lib/docuSign/jwtConfig/README.md @@ -0,0 +1,54 @@ +# DocuSign + +## DocuSign Contract Template + +### DocuSign Delveloper Account + +1. ID: kiman.kim@dtsolution.co.kr +2. PW: rlaks!153 + +### jwtConfig.json + +1. DocuSign Developer 로그인 +2. DocuSign Developer Admin 메뉴 이동 +3. DocuSign 좌측 메뉴 바에서 INTERGRATIONS > Apps and Keys 이동 + +```jwtConfig.json +{ + //Add App and Intergraion Key 시 private.key 파일 생성 (처음 key를 만들때만 저장 가능함.) + "privateKeyLocation": private.key 파일 경로, + "dsJWTClientId": Apps and Intergration Keys 내 Intergration Kzey, + "impersonatedUserGuid": My Account Information 내 User ID, + //개발환경: https://account-d.docusign.com + //운영환경: https://account.docusign.com + "dsOauthServer": "https://account-d.docusign.com" +} +``` + +### DocuSign Web Hook + +1. DocuSign Developer 로그인 +2. DocuSign Developer Admin 메뉴 이동 +3. DocuSign 좌측 메뉴 바에서 INTERGRATIONS > Connect 이동 +4. Add Configuration > Custom +5. Web Hook Url 입력 +6. Trigger Events + 6.1. Envelope Signed/Completed - Check + 6.2. Envelope Declined - Check + 6.3. Recipient Sent - Check + 6.4. Recipient Delivered - Check + 6.5. Recipient Signed/Completed - Check + 6.6. Recipient Declined - Check + +### DocuSign Mail Sender Info Change + +1. DocuSign Developer 로그인 +2. 우측 상단 유저 아이콘 클릭 후 Manage Profile Menu로 이동 +3. My Profile에서 Name 변경 + +### DocuSign Mail Templete Change + +1. DocuSign Developer 로그인 +2. DocuSign Developer Admin 메뉴 이동 +3. DocuSign 좌측 메뉴 바에서 ACCOUNT > Brands 이동 +4. 사용하고자 하는 Brand 제작 후 BrandId 사용 diff --git a/lib/docuSign/jwtConfig/jwtConfig.json b/lib/docuSign/jwtConfig/jwtConfig.json new file mode 100644 index 00000000..756ca9dd --- /dev/null +++ b/lib/docuSign/jwtConfig/jwtConfig.json @@ -0,0 +1,6 @@ +{ + "dsJWTClientId": "4ecf089f-9134-4c6c-9657-d8f8c41b5965", + "impersonatedUserGuid": "de8ef3a2-9498-4855-a571-249a774a3905", + "privateKeyLocation": "./lib/docuSign/jwtConfig/private.key", + "dsOauthServer": "https://account-d.docusign.com" +} diff --git a/lib/docuSign/jwtConfig/private.key b/lib/docuSign/jwtConfig/private.key new file mode 100644 index 00000000..73c4291a --- /dev/null +++ b/lib/docuSign/jwtConfig/private.key @@ -0,0 +1,29 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAnnjspqTabuuPLPi9Iga8U/chJRNmyr1PTbJC/Il0jse4ps/C +KGQdmVOsDzPW//dopMLVc5OmJ7I3y7lw2+TuJ0G7Ip7s6epV2dzqH9aA/yvHDwvj +2W9ZRH8pNx5AjNDCscwBF3NCK8CoGqK3+ukvuErVK8XQHnzOtAF2uyd2JLodT0fE +I+uyvIL1E5pzU5zHzxHoWCsrjKAVaHhWUiTP0migFYrMBMVWC30slvhrNg1qc4uT +Of3rkOGAUK+MFqCbaUm4qKBest9hDgSSw1h8Wv3cKD90KlRgZRSLSRxFwxzhj0ft +1ip+JIc8dLcax1+xhX0dKBW2GARchojxEAzhDQIDAQABAoIBABvVuyF5JsnhU7xv +M09Q9g7cg0SfIAi/0DhiNYxke2Xh1D/ukZilHyLRlND1xs+ebhG0jCf5GO/ziIPe +3mEtWJxqGfvWhOAAUlSKTlBJzc4kKxpsOPj16yzSFhPxmx5ww6XVoqJzEv4a4JwP +FTg78a8R69f8rpXQT8FD2Y49e+2uwVZVJfCjyaLcS2jh0wfaf7YiztSfyeAZNU2z +YIL05wDm6Kw8fsdgZ5tF+tEEx0xBelNh+g4fNVVYdQmUhTM0GHePH5KvLc7LQyxD +z/8ymU5fxikJGFmSS4ncI8ZpmCjV36tkUfZ03n5fW+76Q+gncc+ZKtXRZLgqBdsK +q9ZDTuECgYEAzXMpmOnZh6Mzw6js5WZ2jSw1vuHjEDBOxpKon9UXZD5wZh9bcuxr +ARQy+9/UETppumIW8L+zpmrpZISyriywEkleIjQhDqA9HJGR1lSukhMTyt4bj6ER +f3uyJUzFun5c/QTJEBEJneTFY/Zc4pB+KIdTf3EosVGbtBfkfUXvyyECgYEAxXbA +lg6gmo7ZpGZuPdhMrGiSI8rmGsvIo8Bw7jqdb6E/ksl5nBIxsLcM2lJw8Qe/fvei +g+4Zmc5NOzyOKO1L84ekOC6jfvnGR2jzS2hF/qcNLUEEOKEyzBeniWrAqt80fgeK +cH3zSAXCyLaGJPfdPPqEDYtVBN+zTwNJvHDK5G0CgYEAq1Lcnlpr/vL2iLQGkKno +NINocjw2OFrAZlEIcvik4AA9hLuja+uAs86fUXDujEtUvYtsq+iArEc9R4hs5Ff5 +n9Y0vHsSEftH2tn9bmkBhmiIOcUL4LMlP1TsUrR5srILYycpb891YIjUni5keL6b +pbprw7uefneaSw0dieXXOGECgYBrnmsb3WD+m3hWt1TB9A7lsCBlzYFXfVUemhVy +YRPI8TL6xz+2JdxbGYixvFi9pKFji4dRLAVb5CoHbNt1xs6sLXL9A74rx+mepb5j +jLMJNPZjgZnRW1maDhJLPJlBB2FOhsGWya47xJgCWCgIIea8AzTRROzTOTA6keov +/7E0iQKBgFUWjpHIC0wkBFQFAV1uji3P0Bp6/hCOq9hZNxiaS41AlrhrPDRcIqss +rMrW0Wf0OGDv0+aQXdMkk+nKBjQO3uS6EIj2oDUY/hTFXAKqvDPbHEx3rbtR7NdJ +Sx9/raUX3YoYSNbPwwKcIWiHVnqY/hI8zIb+RFZgwt+mEoLS9/a2 +-----END RSA PRIVATE KEY----- + + diff --git a/lib/docuSign/types.ts b/lib/docuSign/types.ts new file mode 100644 index 00000000..450199ce --- /dev/null +++ b/lib/docuSign/types.ts @@ -0,0 +1,37 @@ +export interface ContractInfo { + tabLabel: string; + value: string; +} + +export interface ContractorInfo { + email: string; + name: string; + roleName: string; +} + +export type poTabLabes = + | "po_no" + | "vendor_name" + | "po_date" + | "project_name" + | "vendor_location" + | "shi_email" + | "vendor_email" + | "po_desc" + | "qty" + | "unit_price" + | "total" + | "grand_total_amount" + | "tax_rate" + | "tax_total" + | "payment_amount" + | "remark"; + +type ContentMap = { + [K in T]: { + tabLabel: K; + value: string; + }; +}; + +export type POContent = ContentMap[poTabLabes][]; -- cgit v1.2.3